美文网首页
并发编程之锁(一)--volatile与synchronized

并发编程之锁(一)--volatile与synchronized

作者: 夏目手札 | 来源:发表于2019-05-07 21:51 被阅读0次

    前言

    本文是对并发编程中的锁一个系统性总结。

    什么是死锁

    1. 定义:
    theadA已经持有了资源2,同时还想申请资源1,theadB已经持有了资源1,同时还想申请资源2,所以theadA与theadB因为相互等待对方已经持有的资源进入死锁状态。
    2. 死锁的四个条件
    互斥条件:指线程对已经获取到的资源进行排他性使用。
    请求并持有条件:指一个线程已经持有至少一个资源同时又想申请新的资源,但是新的资源被其他线程占有,所以该线程会被阻塞,但是阻塞的同时并不释放自己已经获取的资源。
    不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有等待其使用完了。
    环路等待条件:指发生死锁时,必然存在一个线程-资源的环形链。

    什么是线程安全

    当多个线程同时运行某段代码时,如果每次运行的结果与单线程运行的结果一致,而且其他的变量值也与预期的一致,那么我们就说这段代码是线程安全的。

    相关知识

    • 三大特性
      1. 原子性
      指一个线程的操作不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。
      2. 可见性
      指某一个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。
      3. 有序性
      指为了优化程序执行和提高CPU的处理性能,JVM和操作系统都是对指令进行重排,也就是说前面的代码不一定会在后面的代码之前执行。
    • 锁的分类
      1. 可重入锁
      如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁。
      2. 可中断锁
      顾名思义,就是可以响应中断的锁。synchronized就不是可中断锁,而Lock是可中断锁。
      3. 公平锁/非公平锁
      即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。
      4. 独占锁/共享锁
      独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占方式实现的。共享锁可以同时由多个线程持有,ReadWriteLock读写锁,其读锁允许一个资源被多线程同时进行读操作,是共享锁。
      5. 乐观锁/悲观锁
      乐观锁和悲观锁是从数据库中引入的概念,悲观锁一般使用数据库的排他锁来实现的,乐观锁并不会使用数据库提供的锁机制,一般在表中添加version或者使用业务状态来实现。
      悲观锁在Java中的使用,就是利用各种锁。
      乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
      6. 分段锁
      分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
      我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
      当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
      但是,在统计size的时候,就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
      分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
      7. 偏向锁/轻量级锁/重量级锁
      这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
      偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
      轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
      重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
      8. 自旋锁
      在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

    volatile关键字

    上面介绍了多线程相关的三个知识,下面介绍的volatile关键字只具备了其中的可见性和有序性,而并不具备原子性。
    1. 使用介绍
    有volatile关键字修饰的共享变量会在每次更改变量后回写至内存,从而导致其他线程的该变量缓存无效,进而保证了共享变量对所有线程可见。
    下面的方法如果不使用volatile关键字,则永远不会退出,因为主线程对共享变量isStop不可见。

    private static volatile boolean isStop = false;
    public static void stop(){
        isStop=true;
    }
    static class Worker implements Runnable{
    
    
        @Override
        public void run() {
            try {
                java.lang.Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stop();
        }
    }
    
    public static void main(String[] args) {
        new Thread(new Worker()).start();
        while(!isStop){
            //println方法使用了synchronized关键字,如果在这里面打印不用volatile关键字也会退出
            //System.out.println("continue....");
        }
        System.out.println("stop");
    }
    

    2. 原理分析
    volatile 的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JVM 采用了保守策略。
    策略如下:

    • 在每一个 volatile 写操作前面,插入一个 StoreStore 屏障
    • 在每一个 volatile 写操作后面,插入一个 StoreLoad 屏障
    • 在每一个 volatile 读操作后面,插入一个 LoadLoad 屏障
    • 在每一个 volatile 读操作后面,插入一个 LoadStore 屏障

    原因如下:

    • StoreStore 屏障:保证在 volatile 写之前,其前面的所有普通写操作,都已经刷新到主内存中。
    • StoreLoad 屏障:避免 volatile 写,与后面可能有的 volatile 读 / 写操作重排序。
    • LoadLoad 屏障:禁止处理器把上面的 volatile读,与下面的普通读重排序。
    • LoadStore 屏障:禁止处理器把上面的 volatile读,与下面的普通写重排序。

    synchronized关键字

    1. 使用介绍
    synchronized一直是元老级别的存在,是重量级的锁,它比volatile高级,它满足上面的三个特性。一般有如下三种表现形式:

    • 对于普通的方法,锁是当前实例对象;
    • 对于静态方法,锁是当前类的Class实例,又因为 Class 的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程;
    • 对于同步方法块,锁是synchronized括号里面配置的对象。
      下面用代码展示下:
    public static void main(String[] args) throws InterruptedException {
            //1.对于普通方法上加锁,锁是该对象,如果操作的是同一个对象该锁是有左右的
    //      Counter1 counter1 = new Counter1();
    //      Thread thread1 = new Thread(counter1);
    //      Thread thread2 = new Thread(counter1);
    //      thread1.start();
    //      thread2.start();
    //      thread1.join();
    //      thread2.join();
    //      System.out.println(Counter1.i);
    //      此时锁并不生效
    //      Counter1 counter11 = new Counter1();
    //      Counter1 counter12 = new Counter1();
    //      Thread thread3 = new Thread(counter11);
    //      Thread thread4 = new Thread(counter12);
    //      thread3.start();
    //      thread4.start();
    //      thread3.join();
    //      thread4.join();
    //      System.out.println(Counter1.i);
            //2.静态方法锁
    //      Counter2 counter21 = new Counter2();
    //      Counter2 counter22 = new Counter2();
    //      Thread thread5 = new Thread(counter21);
    //      Thread thread6 = new Thread(counter22);
    //      thread5.start();
    //      thread6.start();
    //      thread5.join();
    //      thread6.join();
    //      System.out.println(Counter2.i);
    //      Counter2 counter21 = new Counter2();
    //      Thread thread5 = new Thread(counter21);
    //      Thread thread6 = new Thread(new Runnable() {
    //          @Override
    //          public void run() {
    //              for (int i = 0; i < 1000; i++) {
    //                  counter21.addNoSyn();
    //              }
    //          }
    //      });
    //      thread5.start();
    //      thread6.start();
    //      thread5.join();
    //      thread6.join();
    //      System.out.println(Counter2.i);
            //3.同步代码块
            Counter3 counter31 = new Counter3();
            Counter3 counter32 = new Counter3();
            Thread thread7 = new Thread(counter31);
            Thread thread8 = new Thread(counter32);
            thread7.start();
            thread8.start();
            thread7.join();
            thread8.join();
            System.out.println(Counter3.i);
    
        }
        /**
         * 普通方法锁
         */
        static class  Counter1 implements Runnable{
            static int i=0;
            synchronized void add(){
                i++;
            }
            @Override
            public void run() {
                for (int j = 0; j < 1000000; j++) {
                    add();
                }
            }
        }
    
        /**
         * 静态方法锁
         */
        static class  Counter2 implements Runnable{
            static int i=0;
            static synchronized void add(){
                i++;
            }
            synchronized void addNoSyn(){
                i++;
            }
            @Override
            public void run() {
                for (int j = 0; j < 1000000; j++) {
                    add();
                }
            }
        }
        //锁代码块
        static class  Counter3 implements Runnable{
            static String syn = "true";
            static int i=0;
            @Override
            public void run() {
                synchronized (syn){
                    for (int j = 0; j < 1000000; j++) {
                        i++;
                    }
                }
            }
        }
    

    以上三种其实就是所谓的对象锁与类锁。
    第二个方法也证明了类锁和对象锁互不干涉,add方法锁的是类锁,而addNoSyn锁的是对象锁,两个并不是同一把锁,所以不存在竞争关系。
    2. 原理分析
    使用Classpy工具打开上面我们写的demo(Classpy工具可以从github上下载):
    TestSync$Counter1.class

    TestSync$Counter1.class
    TestSync$Counter3.class
    TestSync$Counter3.class
    我们可以看到:
    • 同步代码块是使用monitorenter和monitorexit指令实现的;
    • 同步方法依靠的是方法修饰符上的ACC_SYNCHRONIZED实现。

    下面我们进一步来分析synchronized的实现:
    2.1 对象头
    synchronized用的锁是存在Java对象头里的。对象头中的数据:

    • Mark Word(存储对象的hashCode和锁信息等)
    • Class Pointer(存储对象类型数据的指针)
    • Array Length(如果当前对象是数组才有的字段,表示数组的长度)
      下面是Java对象头的存储结构(32位虚拟机):


      Java对象头的存储结构

    2.2 Monitor
    Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象,主要特点是互斥和信号机制:

    • 互斥: 一个Monitor锁在同一时刻只能被一个线程占用,其他线程无法占用;
    • 信号机制(signal): 占用Monitor锁失败的线程会暂时放弃竞争并等待某个谓词成真(条件变量),但该条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新竞争锁。

    Monitor Record是Java线程私有的数据结构,每一个线程都有一个可用MR列表,同时还有一个全局的可用列表,其中:

    • 一个被锁住的对象都会和一个MR关联(对象头的MarkWord中的LockWord指向MR的起始地址);
    • MR中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

    其结构如下:


    Monitor Record结构图

    3. 锁优化
    synchronized是重量级锁,在JDK1.6中对synchronized的实现进行了各种优化,比如锁粗化、锁消除、锁升级、自旋锁、适应性自旋锁等技术来减少锁操作的开销。下面我们来看看:
    3.1 锁粗化

    • 定义:多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
    • 例子:
    for(int i=0;i<size;i++){
        synchronized(lock){
        }
    }
    //锁粗化之后
    synchronized(lock){
        for(int i=0;i<size;i++){
        }
    }
    

    JVM 检测到对同一个对象(lock)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到 for 循环之外。这个例子只是一个抽象的概念,实际上这种写法JVM并不会进行所优化,我们来看一个实际的例子:

    public static class CoarsingTest implements Runnable {
            public static String name = "Tom";
    
            @Override
            public void run() {
                //#System.out.println()是加锁的,锁粗化后,name变量具有可见性
                while(!"Bob".equals(name)) {
                    System.out.println("我不是Bob");
                }
                //这种写法,反编译后,#System.out.println()是在循环外面的,所以name是不可见的
    //            while (true) {
    //                if ("Bob".equals(name)) {
    //                    System.out.println(name);
    //                    break;
    //                }
    //            }
            }
        }
    
        public static void main(String[] args) throws InterruptedException {
            CoarsingTest coarsingTest = new CoarsingTest();
            Thread thread = new Thread(coarsingTest);
            thread.start();
            Thread.sleep(1000);
            CoarsingTest.name = "Bob";
        }
    

    3.2 锁消除

    • 定义:锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
    • 例子:
    public static void main(String[] args) throws InterruptedException {
            long tsStart = System.currentTimeMillis();
            for (int i = 0; i < 10000000; i++) {
                getString("AB", "CD");
            }
            //-XX:+DoEscapeAnalysis -XX:+EliminateLocks 开启锁消除模式下999ms
            //-XX:+DoEscapeAnalysis -XX:-EliminateLocks 关闭锁消除模式下1447ms
            System.out.println((System.currentTimeMillis() - tsStart) + " ms");
        }
    
        public static String getString(String s1, String s2) {
            StringBuffer sb = new StringBuffer();
            sb.append(s1);
            sb.append(s2);
            return sb.toString();
        }
    

    根据逃逸分析,变量sb没有逃逸出方法#getString(),所以JVM可以大胆的将StringBuffer内部的锁消除掉。
    3.3 锁升级
    锁的4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)

    • 偏向锁
      引入目的:为了在无多线程竞争的情况下,尽量减少不必要的锁竞争。
      获取与升级:当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
      开启与关闭:偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;这样子默认会进入轻量级锁。
    • 轻量级锁
      引入目的:在竞争锁对象的线程不多,而且线程持有锁的时间也不长的情况下,由于阻塞线程需要CPU从用户态转到内核态,代价较大,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
      获取与升级:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
      如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
      但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
    • 重量级锁
      重量级锁通过对象内部的监视器(Monitor)实现。其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高。


      对比图

    3.4 自旋锁
    定义:所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。其实轻量级锁就是一种自旋锁。
    在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,可以通过参数 -XX:PreBlockSpin 来调整。
    但是这种手动设置自旋次数也不太合理,所以JDK 1.6 引入了更加聪明的自旋锁,即自适应自旋锁。即自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

    总结

    volatile相对于synchronized稍微轻量些,在某些场合它可以替代synchronized,但是又不能完全取代synchronized 。
    volatile经常使用的场景:状态标记变量。

    参考资料

    1. 《深入理解Java虚拟机》
    2. 《Java并发编程的艺术》
    3. Java 8 并发篇 - 冷静分析 Synchronized(下)
    4. 通过踩坑带你读透虚拟机的“锁粗化”
    5. Java锁消除和锁粗化
    6. Java并发——Synchronized关键字和锁升级,详细分析偏向锁和轻量级锁的升级

    相关文章

      网友评论

          本文标题:并发编程之锁(一)--volatile与synchronized

          本文链接:https://www.haomeiwen.com/subject/rmchoqtx.html