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

前言

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

什么是死锁

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关键字和锁升级,详细分析偏向锁和轻量级锁的升级
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342