深究volatile关键字和synchronized关键字

0.前言

如果你有了解过多线程高并发,一定会对volatile和synchronized这两个关键字不陌生,你甚至可以脱口而出保证可见性不保证原子性、轻量级锁和重量级锁等等……但你真的了解它们吗?了解它们的底层原理吗?

或许你知道它们是什么,有什么作用,但如果只是这样是远远不够的。因为在高并发里面,很多东西的原理其实就是运用了这两个东西,各种锁自然不必说,肯定多少与synchronized有关联,cas就运用了volatile,还有许多……

深入了解他们将有利于我们进一步深入学习高并发。本文分成两个部分:第一部分是volatile,第二部分是synchronized。

volatile将会涉及一些计算机的硬件知识,这些拓展的知识是必不可少的,只有了解它们才能深入理解volatile是什么,同时也主要针对volatile的三个特点来讲:保证可见性、禁止重排序和不保证原子性,以及volatile的内存语义。

synchronized也将会深入底层,讲解它到底是如何实现同步的以及什么是锁?以及jdk近几个版本对它的优化升级。


1.volatile

1.1 并发编程三特性

1.1.1 原子性

原子性,指操作是不可中断的,要么执行完成,要么不执行,像一个原子一样无法分割,如果你了解过mysql的事务,相信对这个概念并不陌生。基本数据类型的访问和读写都是具有原子性,当然long和double的非原子性协定除外。

i = 1;		//语句1
i =  j;		//语句2
i ++;		//语句3
i = i+1;	//语句4

以上四个语句,只有语句1具有原子性,因为它只有单纯的赋值。而对于其他三个语句,语句2有两个动作:变量j的初始化和变量i的赋值;语句3有三个动作:i的初始化、i的自增、i的赋值;语句4和语句3是一样的,都有三个动作。

以上这些一条语句同时包含多个动作的行为,就叫做不具有原子性,因为他们是可分割的。有可能只完成了第一个动作,第二个动作失败了的情况,所以就不具有原子性。

那在多线程中,不具备原子性有什么问题呢?看下面例子:

private int a = 0;

@Test
void threadTest() throws InterruptedException {

   Thread thread1 = new Thread(new Runnable() {
       @Override
       public void run() {
           for (int i = 0; i < 100; i++) {
               try {
                   Thread.sleep(100);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               a++;
           }
       }
   });
   Thread thread2 = new Thread(new Runnable() {
       @Override
       public void run() {
           for (int i = 0; i < 100; i++) {
               try {
                   Thread.sleep(100);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               a++;
           }
       }
   });
   thread1.start();
   thread2.start();
   Thread.sleep(30000);
   System.out.println(a);
}

结果不是200,结果出错

出错分析

  1. 因为a++这个自增操作不是原子操作,它是由三步组成的:a的初始化,a的自增,a的赋值。

  2. 现在有两个线程分别来对它进行自增操作,现在具有这样一种情况:

    1. 线程1刚完成了对a的初始化,读取到a的值是55,然后cpu时间片结束;
    2. 轮到线程2来做事了,由于线程1还没来得及修改,所以线程2读取到的也是55,然后它完成了a的自增操作,a变成了56;
    3. 然后轮到线程1,它接着之前的动作,它读取到的a的值是55,然后对它自增变成了56。
    4. 你看这样就出现错误了,两个线程对它完成了自增操作,结果却只自增1。
  3. 这里也引出了一个问题:为什么volatile不能保证原子性?就这个例子解释一下:在第二步线程2完成了自增,然后根据volatile的内存语义,这个时候它会把56从缓存写会主存,然后使线程1的缓存失效。

    然后到了第三步,虽然线程1的缓存失效了,但是线程1已经不用再去从缓存里面读数据了,在第一步的时候他已经读好了,它已经过了读操作了,可见性在这里保证的是线程1在第一步读的时候没错,虽然这时它的缓存是失效的,那也只有等到下次读的时候它才会重新从主存中读取最新值。


1.1.2 可见性

可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

如果没有保证可见性在多线程又有什么问题呢,举例说明一下:

public class ThreadDemo extends Thread {
 
    private  Boolean stop = false;
 
    public static void main(String[] args) throws InterruptedException {
        ThreadDemo t = new ThreadDemo();
        t.start();
        Thread.sleep(1000);
        t.stopMe();
    }
 
    @Override
    public void run() {
        int i = 0;
        while (!stop) {
            i++;
        }
        System.out.println(i);
    }
 
    public void stopMe() {
        System.out.println(" STOP--------");
        this.stop = true;
    }
}

结果:
STOP--------(进程未停止)

我们的设想是让new出来的线程循环一下子,然后主线程调用stop方法停下线程;但结果是主线程调用了stop方法,但new出来的线程并没有停止,而是一直在循环。这又是为什么呢?

出错分析

  1. 在Java的内存模型中,cpu读取的变量都不是直接从主存去拿取的,而是去高速缓存去拿取的。
  2. 线程1调用了stop方法,改变了stop变量变成true,并将stop变量新值写到了主存里。
  3. 但线程2读取到的stop变量依然是他自己的缓存里的值,依然是false,因为它在进行while循环且while循环判断条件简单,所以效率很高,它没空去主存中刷新数据,所以即使线程1改了数据它也不知道,这就是线程的可见性问题。

解决方法也很简单,给共享变量stop加上volatile修饰即可。


1.1.3 有序性

有序性,即程序执行的顺序按照代码的先后顺序执行。大家看到这里,可能会很疑惑,代码不就是按先后顺序执行的吗?答案是不一定,我们的代码即源程序在变成程序运行这一个过程中,经过了很多处理。

为了提高性能,JVM虚拟机对代码的处理有一个指令重排序,它就是对我们的代码顺序进行重新排序,使得它更加有利于JVM的运行,当然这个是不会影响我们的得到的结果的。举个例子:

int x = 10;    //语句1
int y = 5;     //语句2
x = x + 5;     //语句3
y = x * x;     //语句4

对于上面这四个语句,我们过去的思维以为它就是按顺序执行的,但事实是不一定,他可能是2134,又或者是1324,只要不影响最终结果,Java会对我们的代码进行重排序,使得它更有利于JVM运行这个代码。

指令重排又是什么呢?指令重排是指在程序执行过程中,为了提高性能, 编译器和CPU可能会对指令进行重新排序。CPU重排序包括指令并行重排序和内存系统重排序,重排序类型和重排序执行过程如下:
在这里插入图片描述

那这个时候你可能会有疑问?重排就重排咯,又不会影响结果。确实,在单线程的时候确实不会出现什么问题,但一旦到了多线程,问题就又来了:

public class ThreadDemo extends Thread {

    private boolean flag = false;
    private int b = 0;

    public void read() {
        b = 1;              //1
        flag = true;        //2
    }

    public void add() {
        if (flag) {         //3
            int sum =b+b;   //4
            System.out.println("bb sum is"+sum);
        }
    }

    public static void main(String[] args) {
        ThreadDemo demo = new ThreadDemo();
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.read();
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                demo.add();
            }
        });
        thread1.start();
        thread2.start();
        System.out.println(demo.b);
    }
}

如果是单线程,结果应该没问题,如果是多线程,线程t1对步骤1和2进行了指令重排序呢?上面代码结果是正确的,但是这里做一个假设:假如read方法发生重排序了:结果sum就不是2了,而是0,如下图所示:
在这里插入图片描述
解决方法有两个:用volatile修饰或者用synchronized、lock。

  • volatile:禁止指令重排序,就不会出现有序性问题,下面详细讲。
  • synchronized、lock:加锁,线程变成单线程自然不会出错。

其实Java为保证有序性不出问题,不仅只有volatile或者加锁,它还有一个happen before原则,比如在单线程里即使重排序也不能影响结果就是它的一条规则,下面了解一下:

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
  • 管程锁定规则:一个unLock操作先行发生于后面对同一个锁lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程终止规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
  • 传递性:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

1.2 计算机内存模型

我们都知道,在计算机中指令是由CPU执行的,数据是由内存存储的,CPU执行指令需要从内存中读取数据,但CPU执行指令速度是高于内存读取数据几个量级的,总不能每次CPU执行完指令,然后等主内存慢悠悠存取数据吧, 所以现代计算机系统加入一层读写速度接近处理器运算速度的高速缓存(Cache),以作为来作为内存与处理器之间的缓冲。

在多核CPU计算机中,每个处理器都有自己的高速缓存,而它们共享同一主内存。计算机抽象内存模型如下:
在这里插入图片描述

  • 程序执行时,把需要用到的数据,从主内存拷贝一份到高速缓存。
  • CPU处理器计算时,从它的高速缓存中读取,把计算完的数据写入高速缓存。
  • 当程序运算结束,把高速缓存的数据刷新会主内存。

随着科学技术的发展,为了效率,高速缓存又衍生出一级缓存(L1),二级缓存(L2),甚至三级缓存(L3):
在这里插入图片描述
当多个CPU对共享的内存数据进行操作时,就会出现缓存不一致的问题,也就是我们上面提到的可见性问题,那在计算机硬件层面又是怎么解决这个问题的呢?

  • 总线锁
    先了解下总线是什么?总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束, 按照计算机所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。

    类似于synchronized一样,对一个对象上了锁只有获得锁的这个线程能操作资源;加了总线锁就是对总线上了锁,只有获得锁的CPU才能操作计算机其他硬件比如内存等,当然这里并不是真正上了锁,这个锁和synchronized的锁还是有区别的,这里只是一种比喻。

    当然,缺点也很明显,资源浪费太大了,直接锁上总线,其他CPU都不用干活了。

  • 缓存一致性协议(MESI)
    为了解决一致性问题,还可以通过缓存一致性协议。即各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,有许多协议,其中比较出名的就是MESI协议,所以下面的缓存一致性协议也讲的就是MESI。

    • MESI是什么?

      当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的缓存,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

      CPU中每个缓存行标记的4种状态(M、E、S、I),也了解一下吧:

    缓存状态描述
    M被修改(Modified) 该缓存行只被该CPU缓存,与主存的值不同,会在它被其他CPU读取之前写入内存,并设置为Shared
    E独享的(Exclusive) 该缓存行只被该CPU缓存,与主存的值相同,被其他CPU读取时置为Shared,被其他CPU写时置为Modified
    S共享的(Shared) 该缓存行可能被多个CPU缓存,各个缓存中的数据与主存数据相同
    I无效的(Invalid) 该缓存行数据是无效,需要时需重新从主存载入
    • MESI怎么实现?总线嗅探技术。

    每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是过期了,如果处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据库读到处理器缓存中。


1.3 volatile作用与原理

终于来到了volatile了,如果要深入了解volatile,上面那些补充内容是必不可少的。提到volatile,绕不开的三个点:

  • 不保证原子性
  • 保证可见性
  • 禁止重排序

这个三个点就是volatile的作用和特点了,不保证原子性这一点在上面的原子性介绍里已经讲过了,这里不再多讲。那其余两点呢?volatile是怎么保证可见性和禁止重排序的?

首先来看下volatile的层层实现原理:
在这里插入图片描述
我们看到的volatile的作用就是保证可见性、禁止重排;然后在Java代码里的实现就是直接用volatile修饰变量而已;然后Java代码编译变成字节码,就是字节码.class文件里多出一个指令ACC_VOLATILE,对这些有个简单了解即可。

重点是JVM的规范实现——内存屏障。内存屏障四大分类:(Load 代表读取指令,Store代表写入指令)

在这里插入图片描述

且满足以下规则:

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

在这里插入图片描述

  • volatile写

    • StoreStore:写写屏障,在执行volatile写之前的写操作都完成了
    • StoreLoad:写读屏障,在执行volatile写之后的操作,volatile已经写好了
  • volatile读

    • LoadLoad:读读屏障,在执行volatile读之前的读操作已经完成了
    • LoadStore:读写屏障,在执行volatile读之后的操作,volatile已经读好了

这样内存屏障便实现了可见性和禁止重排序!!!但它只是一个规范,只是一个逻辑上的实现,还要具体去实现(硬件层面的实现,最下面的CPU实现),那JVM怎么实现呢?JVM的实现很简单,它将字节码文件转化成汇编文件,这个汇编文件里会有一条指令lock addl $0x0,(%esp)

那这条指令有什么作用呢?这就到CPU层面的实现了,这个lock为前缀的指令在计算机中会引起两个反应:

  1. 将当前CPU缓存行的数据写回主存
  2. 一个CPU的缓存回写到内存会导致其他CPU的缓存无效

那这两个反应的原理又是什么呢?原理就是缓存一致性,它会强迫数据写会主存,当新数据写回主存的时候,其他CPU的嗅探技术嗅探到数据不一样了,会使它们各自的缓存失效,下次需要使用该失效数据的时候,会重新从主存中获取,这样就保证了数据的可见性。

但禁止重排序呢?缓存一致性并不能实现禁止重排序,实现禁止重排序是用原语保证的,这个了解有这样一个东西即可,这个原语就是CPU层面的内存屏障,且不同CPU的支持不同的原语
在这里插入图片描述

当然还有另外一种简单粗暴的方法,那就是总线锁,它既能保证可见性,也能实现禁止重排序,虽然它开销较大,但由于所有的计算机都支持,而原语在不同的CPU实现起来是不同的比较麻烦,所以现在有的还是用的总线锁。

说浅点,volatile有两个作用,禁止重排和保证可见性。

说深点,volatile强制数据写回内存,并使其他CPU中的缓存失效,它们再次使用数据的时候必须重新从主存中读取。

假设flag变量的初始值false,现在有两条线程t1和t2要访问它,就可以简化为以下图:

在这里插入图片描述

如果线程t1执行以下代码语句,并且flag没有volatile修饰的话;t1刚修改完flag的值,还没来得及刷新到主内存,t2又跑过来读取了,很容易就数据flag不一致了,如下:
在这里插入图片描述

flag=true;

如果flag变量是由volatile修饰的话,就不一样了,如果线程t1修改了flag值,volatile能保证修饰的flag变量后,可以立即同步回主内存。如图:

在这里插入图片描述

细心的朋友会发现,线程t2不还是flag旧的值吗,这不还有问题嘛?其实volatile还有一个保证,就是一旦数据被在别的线程被写回了以后,这个数据已经过期了,线程t1修改完后,线程t2的变量副本会过期了,需再次从主存中获取数据,如图:
在这里插入图片描述


2.synchronized

2.1 synchronized应用

在这里插入图片描述

按应用方式可分为:

  • 同步代码块:用synchronized圈起来的代码块,此代码块会被锁住
    synchronized (this){
        ...
    }
    
  • 同步方法:直接用synchronized修饰的方法,此方法会被锁住
    private synchronized void test(){
        ...
    }
    

下面将会介绍到两个比较重要的概念:类锁和对象锁,在说之前我们要先明确一点:锁到底锁住了什么?你可能会说同步代码块锁住了那个代码块,同步方法锁住了那个方法,但仅仅只是这样吗?看个例子:

public class test {
    static class Phone {
        public synchronized void sendEmail() throws InterruptedException {
            Thread.sleep(500);
            System.out.println("*****sendEmail");
        }

        public synchronized void sendSMS() {
            System.out.println("*****sendSMS");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Phone phone = new Phone();
        new Thread(()->{
            try{
                phone.sendEmail();
            }catch (Exception e){
                e.printStackTrace();
            }
        },"A").start();

        Thread.sleep(100);

        new Thread(()->{
            try{
                phone.sendSMS();
            }catch (Exception e){
                e.printStackTrace();
            }
        },"B").start();
    }
}

对这个例子,猜一下结果是什么?我们可以看出一定是线程A先执行sendEmail方法,线程B后调用sendSMS方法,但sendEmail方法需要一定时间,所以猜想结果是sendEmail方法先,sendEmail方法慢。

但实际上是反过来的,是sendEmail方法慢,sendEmail方法先,为什么会这样呢?原因就是它们两个都是同步方法,且都是普通的同步方法,它们都被锁住了。你可能会有疑问了:啊为啥啊,它们又不是同一个方法,还能被锁住的吗?

对的,我们所说的锁锁住的并不单单只是一个方法,不单单只是代码,而是对象我们根据锁的目标分成类锁和对象锁,一个锁锁住的是一个实例对象的所有同步实例方法,或者是一个类的所有同步静态方法(这就是类锁,也就是我们学java时静态方法是属于类的这个说法)。

按锁住的目标可分为:

  • 类锁:锁住一个类(其实就是就是一个类的class对象)的所有同步静态方法

    • 同步代码块:锁住的是class对象
    • 同步方法:静态方法
     private synchronized static void test1(){
        ...
    }
    
    private void test2(){
        synchronized (Test.class){
            ...
        }
    }
    
  • 对象锁:锁住一个对象的所有同步实例方法

    • 同步代码块:锁住的是实例对象
    • 同步方法:实例方法
    private synchronized void test1(){
        ...
    }
    
    private void test2(){
        synchronized (new Test()){
            ...
        }
    }
    

而且要记得,同步方法默认锁的是含有同步方法的那个类或者那个类实例化出来的对象,相当于同步代码块里synchronized(this)

看下面例子,结果是SMS方法先执行,因为同步方法锁的是this,而同步代码块锁的是一个new出来的phone实例对象,两者是不一样的所以不会被锁上。

假如讲同步代码块里改成synchronized(this),那这样就会被锁上了,会先执行email方法。

static class Phone {
	public synchronized void sendEmail() throws InterruptedException {
	    Thread.sleep(500);
	    System.out.println("*****sendEmail");
	}
	
	public  void sendSMS() {
	    Phone newPhone  = new Phone();
            synchronized (newPhone){
	        System.out.println("*****sendSMS");
	    }
	}
}

注意一点:锁住的是同步方法,非同步方法是不会受影响的,不同类型的锁锁住的方法也是不受影响的,都举一些例子吧:

  1. 同步方法和非同步方法不互相影响,比如还是上面那个例子,把SMS方法改成普通方法,此时就会是SMS方法先执行了。
     static class Phone {
        public synchronized void sendEmail() {
            System.out.println("*****sendEmail");
        }
    
        public void sendSMS() {
            System.out.println("*****sendSMS");
        }
    }
    
  2. 对象锁和类锁的方法都不互相影响,此时这里也是SMS方法先执行。
    static class Phone {
        public synchronized void sendEmail() {
            System.out.println("*****sendEmail");
        }
    
        public synchronized static void sendSMS() {
            System.out.println("*****sendSMS");
        }
    }
    

其实还是比较好理解的,但有一点要注意:类锁类锁,其实本质还是一个对象锁,就是一个类的class对象的锁,不过一个类只有一个,也不要想成类锁就是锁住了一个类,其实锁的只是一个class对象。

2.2 synchronized原理

2.2.1 同步代码块和同步方法原理

对于synchronized的原理我们来一层一层的往下剥,首先我们来看下同步代码块和同步方法的字节码文件。

  • 同步代码块

    public class SynchronizedTest{
        public void doSth(){
            synchronized (SynchronizedTest.class){
                System.out.println("test");
            }
        }
    }
    

    对上面这个代码块,我们反编译得到下面字节码文件,由图可得,添加了synchronized关键字的代码块,多了两个指令monitorenter、monitorexit。即JVM使用monitorenter和monitorexit两个指令实现同步
    在这里插入图片描述

  • 同步方法

    public synchronized void doSth(){
       System.out.println("test");
    }
    

    对这个同步方法,反编译得一下字节码文件,由图可得,添加了synchronized关键字的方法,多了ACCSYNCHRONIZED标记。即JVM通过在方法访问标识符(flags)中加入ACCSYNCHRONIZED来实现同步功能。

    在这里插入图片描述
    好的我们已经剥开第一层字节码文件,接下来我们来看下monitorenter、monitorexit指令和ACCSYNCHRONIZED标记都是什么。

  • monitorenter
    每个对象都与一个monitor 相关联。当且仅当拥有所有者时(被拥有),monitor才会被锁定。执行到monitorenter指令的线程,会尝试去获得对应的monitor,如下:

    • 每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1.

    • 当同一个线程再次获得该对象的锁的时候,计数器再次自增.

    • 当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。
      在这里插入图片描述

  • monitorexit
    monitor的拥有者线程才能执行 monitorexit指令。

    线程执行monitorexit指令,就会让monitor的计数器减一。如果计数器为0,表明该线程不再拥有monitor。其他线程就允许尝试去获得该monitor了。

在这里插入图片描述

  • ACCSYNCHRONIZED
    方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。

    当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。

    在这期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。

    如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
    在这里插入图片描述

总结一下:

  • 同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。

  • 同步方法是通过中设置ACCSYNCHRONIZED标志来实现,当线程执行有ACCSYNCHRONI标志的方法,需要获得monitor锁。

  • 每个对象维护一个加锁计数器,为0表示可以被其他线程获得锁,不为0时,只有当前锁的线程才能再次获得锁。

  • 同步方法和同步代码块底层都是通过monitor来实现同步的。

  • 每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

2.2.2 monitor锁

上面讲了那么多与monitor相关的指令,那什么是monitor呢?对于这个,下面是我个人对于monitor的理解:我们都说每一个对象都有一把属于自己的锁,其实这把锁指的就是monitor。

monitor是什么? 它可以理解是一种同步机制。它通常被描述成一个对象。操作系统的管程是概念原理,在Java里ObjectMonitor是它的原理实现。

来看一下monitor的工作过程:

  • 想要获取monitor的线程,首先会进入_EntryList队列。
  • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  • 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将- -
  • owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll() ,会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。
    在这里插入图片描述

举个简单例子理解:

synchronized(this){//进入_EntryList队列
   	doSth();          
   	this.wait();//进入_WaitSet队列      
}

那么ObjectMonitor又是什么呢?它是一个类在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下

在这里插入图片描述
其中一些比较重要的字段如下:

在这里插入图片描述

那对象头又是什么?monitor和对象又有什么关系?下面将会讲解到。

2.2.3 对象头

直接上图吧:

在这里插入图片描述
在这里又要做一些知识的扩展了,大概有以下这些内容:对象内存布局、对象头、Mark Word。

  • 对象内存布局
    在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据(Instance Data)和对象填充(Padding),对这些有个了解就好,主要是对象头。
    在这里插入图片描述
    • 实例数据:对象真正存储的有效信息,存放类的属性数据信息,包括父类的属性信息;

    • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

    • 对象头:Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
      在这里插入图片描述

      • Class Pointer:是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

      • Mark Word : 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

        Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

        在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间里的25位用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,表示非偏向锁。其他状态如下图所示:

      在这里插入图片描述

现在已经很清楚了吧,其实这就是一个套娃:一个对象有对象头,对象头有mark word,makr word里又记录了一个指向ObjectMonitor的指针。

其实一个线程进入同步方法或者同步代码块获取了锁(monitor),我个人觉得是一个很容易混淆别人的叫法,一个线程获取了锁,其实就是修改了某一个对象里的对象头指向的Objectonitor的字段,_owner指向了获取的那个线程,然后_count+1表示这个锁被获取了,当然还有其他字段被修改了,这里只是简单描述下

且要注意,这里说的只是synchronized的重量级锁的原理,在最近的jdk版本里已经对synchronized进行了优化,synchronized不一定是重量级锁了,有可能是偏向锁或者自旋锁,它们的原理与重量级锁完全不同。

2.3 锁优化

2.3.1 用户态和内核态

在讲锁优化之前,我们要先明白为什么要进行锁优化,synchronized有什么问题吗?有,性能问题,而且很严重,以至于现在一提到synchronized许多人第一印象就是重量级锁,它太重了,这个“重”到底是重在哪里?这就与接下来的用户态和内核态相关了。

多数CPU都有两种模式,即内核态和用户态模式。

  • 当在内核态运行时,CPU可以执行指令集中的每一个指令,并且使用硬件的每个功能,包括硬盘、网卡。
  • 当在用户态运行时,访问的资源受到限制, 且不允许访问外围设备. 占用CPU的能力被剥夺, CPU资源可以被其他程序获取。

通俗一点来说,就是有两种权限,一种是内核权限能操作所有操作,一种是用户权限,只能进行一些“对于计算机不是那么危险”的操作。

在很久之前用户态和内核态是没有分开的,这样其实是十分危险的,很容易就造成死机。

在这里插入图片描述

正如上图,处于用户态是无法直接访问某些操作的,需向操作系统发送请求,转换成内核态才能进行操作,那这个和我们的重量级锁又有什么关系呢?

这里的进行某些操作就是加锁啦,当我们使用重量级锁加锁的时候就要进行一次用户态转换成内核态,而这个转换过程是要耗费资源的,而相比起其他锁,比如偏向锁、自旋锁,它们有独特的实现的方式,是不要经过这个转换的,所以也就少了资源损耗,这也是为什么重量级锁重的原因。

所以我们要对synchronized进行优化,使他更“轻”一点,具体怎么优化呢?先看下另外的两种锁,最后会介绍它的优化之路。

在讲其他两种锁之前,再看下这个图:

在这里插入图片描述

2.3.2 偏向锁

实际上在我们的程序里,大多数时候锁不仅不存在多线程竞争,甚至老是同一个线程获得,比如StringBuffer的append方法,我们有多少时候是多线程同时执行这个方法呢?但是不加锁又不行,它存在线程安全问题,但用了又浪费资源,这时候偏向锁就出场了。

所以由以上我们可以得出偏向锁的应用场景:用于解决存在线程问题,但基本是不存在线程竞争的情况。那我们再想一下,在这种情况下,普通的锁有什么缺点呢?

性能损耗大,每一次加锁解锁都要损耗性能,但这种性能损耗在这里完全是一种浪费,因为根本没竞争而且还是同一个线程,然而偏向锁却适用于这个场景,所以我们可以再得出一点:偏向锁的加锁和解锁很简单,消耗的性能很低。

事实确实如此,偏向锁加锁的时候性能损耗很小,下面将对偏向锁的获取和释放做一个详解:

  • 偏向锁的获取和撤销

    这一部分很重要,了解这一步我们就可以充分理解偏向锁。但很可惜,网上许多博客还有包括并发编程神书:《并发编程的艺术》对这一步都写得很混乱(个人认为),极其难以理解,它们将获取和撤销分开来讲,个人觉得应该混在一起比较好懂。

    先看图吧,比较容易理解,这个图画到我裂开:

    在这里插入图片描述

这里不再对详细步骤细讲了,看图应该就清晰了,这里主要讲下几个点:

  • 加锁:mark word锁状态变成偏向锁的锁状态,且存储了线程ID
  • 撤销:偏向锁状态变成其他锁状态,线程ID也变成其他锁的mark word
  • 线程ID记录和锁状态存在于对象头里的mark word。
  • 偏向锁的撤销记录如果超过一个值,就会被判定为不适合作为偏向锁,会直接升级成轻量级锁,这个值应该是40。
  • 一旦有两个线程要获取锁,就会出现偏向锁的撤销,也有可能会出现偏向锁的升级。
  • 偏向锁只有在处于全局安全点(这个时间点没有正在执行的字节码)的时候才能撤销。

好了偏向锁最难的一步已经过去了,接下来再看一些其他七七八八的细节:

  • 偏向锁是默认开启的,我们也可以自己设置不启用偏向锁直接变成轻量级锁。
  • 偏向锁启动是有延时的,默认是4秒,这里是为什么我也没有深究,同时我们也可以自定义是否延时或者修改延时时间。
2.3.3 自旋锁(轻量级锁)

终于到轻量级锁了,一般也叫自旋锁,还有一个比较离谱的叫法叫无锁,不要叫这个很容易混淆真正的无锁。它的实现原理就是大名鼎鼎的CAS

偏向锁是用于只有一个线程的情况,而自旋锁就是用于有多个线程竞争的情况。且它相比起重量级锁,它和偏向锁一样都是不用切换用户态到内核态的,它的开销也比较小。

来看下自旋锁的优点:在重量级锁中,一个线程如果竞争失败就会被阻塞然后挂起,然后就得一些方法再次唤醒它们,这个过程也是十分消耗性能的。而自旋锁,一个线程一旦竞争锁失败,不会直接阻塞挂起,而是会不断自旋(while死循环),不断地尝试获取锁,直至获取成功。

还是老套路,通过轻量级锁的获取和释放来深入了解它,上图:
在这里插入图片描述

自旋锁的工作机理比较简单,上面这个图只是讲了个大概,还有几点需要讲一下:

  • lock record:轻量级加锁之前要做一些准备工作,会在准备获取锁的线程的栈帧里开辟一个存储锁记录的空间,这个空间用来保存对象头里的mark word,也叫做lock record。

  • 加锁:轻量级加锁远没有偏向锁那么简单,一个线程获得了锁,就是将对线头里的mark word保存到线程在准备阶段创建的lock record里,然后对象头里的mark word替换成一个指向lock record的指针,而不单单只是一个线程ID了。

  • 解锁:一定是获得了轻量级锁的线程执行完毕才会解锁,不像偏向锁会被强制解锁,判断mark word里是否是当前线程的lock record的指针,且mark word的信息和lock record里是否相同:不同,则说明变成了重量级锁;相同,则将lock record里的mark word还给对象头。

  • 自旋时间过长会失败,造成锁升级为重量级锁,如何才算过长呢?在以前需要我们手动设置参数,现在JVM已经帮我自动判断了,这个操作也叫自适应自旋。

    • 自适应自旋
      • 此操作为了防止长时间的自旋,在自旋操作上加了一些限制条件。
      • 比如一开始给线程自旋的时间是10秒,如果线程在这个时间内获得了锁,那么就认为这个线程比较容易获得锁,就会适当的加长它的自旋时间。
      • 如果这个线程在规定时间内没有获得到锁,并且阻塞了。那么就认为这个线程不容易获得锁,下次当这个线程进行自旋的时候会减少它的自旋时间

不过还是留下一个问题:偏向锁是怎么样变成轻量级锁的,是直接转换吗?还是先变成无锁,再变成轻量级锁?mark word里怎么变?这个我找资料找不到哈哈,有大神的话请在评论区告诉我,谢谢您。
个人认为应该是先变成无锁,在变成轻量级锁。mark word里的线程ID会被清除变回hashcode,锁状态变为无锁,然后再变成轻量级锁。

2.3.4 锁升级过程

终于来到锁升级,在此之前问一个问题:直接使用偏向锁一定比直接使用自旋锁效率高吗?答案是不一定,假如你明确知道是有多个线程竞争的,这时直接使用自旋锁效率就比较高,因为如果使用偏向锁是一定要进行偏向锁升级撤销的,这时就会多出这一步没必要的消耗了。

同理,这个问题也可以推出轻量级锁不一定比重量级锁效率高,因为轻量级锁虽然开销较小,但它是在不停自旋的,是要占用CPU资源的,如果线程少竞争低那还好说,自旋一下子就好了;如果竞争激烈,线程一直在自旋一直得不到锁,这时就会占用大量资源,这也就是为什么要设置自适应自旋的原因。

在这里插入图片描述
以上这个图就是锁升级的过程了,其实我们上面在讲两种锁的时候或多或少都提到过了。

  1. 无锁、偏向锁、自旋锁、重量级锁,这几个锁从小到大越来越重,但同步性能越来越好,且锁升级是单向的,一旦升级不会退级。
  2. 为什么new出来的对象有些未启动偏向锁,有些启动偏向锁呢?这就是偏向锁里面讲到的是否有延迟启动,其实这里应该还要有一条线,就是直接启用轻量级锁,也就是我们上面讲到的偏向锁和自旋锁的效率问题。

锁升级大概也就这样了,其实synchronized已经不是一个很重的锁了,不要再对它有可悲印象啦。

2.3.5 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

2.3.6 锁粗化

锁粗化概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

public String test(String str){
       
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值