面试官再提到synchronized时,用这篇文章彻底征服他!

697 阅读15分钟

一、synchronized关键字出现的意义

一个新事物的出现必然是为了解决当时已有的资源所不能解决的问题,synchronized关键字的出现使得安全访问共享资源成为了可能。synchronized关键字也被称为同步锁,它保证了一段代码的原子性,当一个线程进入该段代码时拿到一把锁,离开该段代码时释放这把锁,让这把锁能够被其他线程拿到。

二、不同的使用方式及区别

1、synchronized修饰静态方法

public class Test {
    
    public synchronized static void method1() {
        // ......
    }
    
    public static void main(String[] args) {
        
    }
}

synchronized修饰静态方法拿到的是一个类锁,可以看成是Test.class这个类的锁,当多个线程同时访问method1方法时,只有一个线程能够拿到类锁并进入方法,其他线程在锁外等待,方法执行完后拿到锁的线程释放类锁,然后多个线程继续争夺类锁,最终又有一个线程抢到了类锁并循环以上步骤。

2、synchronized修饰实例方法

public class Test {
    
    public synchronized void method2() {
        // ......
    }
    
    public static void main(String[] args) {
        
    }
}

synchronized修饰静态方法拿到的是一个对象锁,可以看成是Test这个类的一个实例的锁,当多个线程同时访问同一个Test实例的method1方法时,只有一个线程能够拿到对象锁并进入实例方法,其他线程在锁外等待,方法执行完后拿到锁的线程释放对象锁,然后多个线程继续争夺锁,最终又有一个线程抢到了对象锁并循环以上步骤。

类锁与对象锁

类锁锁住的是Test.class这个类,对象锁锁住的是Test这个类产生的实例对象,一个类可以产生多个实例,这些实例它们的对象锁之间是互不影响的。比如

Test test1 = new Test();
Test test2 = new Test();

假如test1拿到了method2的锁,那么test2也能同时获取它自己的method2的锁,因为这两个锁本身就不是一把锁,各自拿各自的锁没毛病,你给test1加锁管我test2什么事

类锁和对象锁在使用时是两把锁,各自之间也并没有直接联系,一个线程可以同时占有Test.class类锁和new Test()对象锁

3、synchronized修饰同步代码块

    public class Test {
        public void method1() {
            synchronized(this) {
                // ......
            }
        }
        
        public void method2() {
            synchronized(Object.class) {
                // ......
            }
        }
    
        public static void main(String[] args) {
    
        }
    }

synchronized修饰同步代码块拿到的是一个括号中的锁,如method1方法拿到的就是Test()对象的实例,是一个对象锁,method2方法拿到的就是Object.class类锁,锁住的都是括号中的类或对象实例

三、深入synchronized内部实现

Java对象头

我们都知道synchronized关键字相当于给对象加了一把锁,那么问题来了,这把锁到底存储在哪呢,锁的信息如何变化?那么这就需要我们去了解Java对象头了,事实上锁的信息就是利用对象头来保存和更新的

Java对象头分为三部分:

  • 存储对象自身的运行时数据,官方称为Mark Word
  • 存储指向对象的类型元数据的指针
  • 如果对象是个数组,存储数组长度

在这里插入图片描述

重点就在Mark Word这里了,事实上对象自身的运行时数据非常多,已经超出了32/64 bit所能存储的最大限度了,因此Mark Word被设计成一个动态变化的结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间

锁的状态变化

在Java 1.6之前synchronized关键字被广大程序员认为是重量级锁,很少会使用它,在Java 1.6时HotSpot虚拟机开发团队在这个版本上花费了大量的资源去实现各种锁优化技术,那么synchronized关键字就是其中主要被优化产物之一,优化后引入了“偏向锁”和“轻量级锁”,减少了获得锁与释放锁的性能消耗,那么这时就可以分成4种锁状态了,即无锁-->偏向锁-->轻量级锁-->重量级锁,如下给出32 bit下的Mark Word

在这里插入图片描述

在这里插入图片描述

偏向锁

为什么要引入偏向锁?

HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁就是偏向获得锁的线程,拥有偏向锁的线程下一次再次获取锁时将不用再次获取锁,直接就能进入同步块中执行代码,减少了获得锁和释放锁的开销,看起来就像是没加synchronized的执行过程一样(整个过程只需第一次获取偏向锁时获得锁,撤销偏向锁时释放锁)

获取偏向锁过程

  1. 首先根据Mark Word判断对象是否可使用偏向锁:如果可用,进行下一步;如果不可用,则直接升级锁
  2. 根据偏向锁标识测试是否是偏向锁:如果是,则进行下一步;如果不是,说明为无锁状态,进行CAS获取锁
  3. 测试Mark Word中线程ID是否指向自己:如果是,则已获得锁;如果不是,则尝试使用CAS将对象头的偏向锁指向当前线程

第一步中偏向锁什么时候会不可用?

从Mark Word中可以看出无锁状态时对象是有一段空间用于存储hashcode的。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了,因为它将无法存储哈希码;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码

撤销偏向锁

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(暂停所有用户线程)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态

轻量级锁

为什么需要轻量级锁?

根据“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销。这是乐观锁的一种机制,乐观锁即会乐观的认为当前没有锁的竞争,因此只需要CAS操作判断当前是否有线程竞争,如果没有竞争则成功直接执行,如果失败了则说明有竞争取消本次操作的执行。往往很多时候乐观锁可以搭配自旋(自旋锁)使用保证CAS成功,自旋即死循环执行CAS操作直至成功。

轻量级锁加锁过程

虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图所示

在这里插入图片描述

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。

  • 如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图所示

在这里插入图片描述

  • 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了,进行自旋(一定次数内)。
  • 如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。 因为如果出现两个线程以上的线程争抢锁(一个持有锁,一个自旋,另外还有一个线程争抢锁),那么虚拟机认为当前锁的竞争已经很激烈了,再用乐观锁反而会降低效率

这里可能很多小朋友会疑问为什么更新操作失败了还要检查对象Mark Word是否指向当前栈帧,既然更新失败了不应该就是没抢到锁吗? 笔者认为这是为了保持锁的可重入性,可重入锁即线程第一次获取锁后,可以在不解锁第一个锁的情况下第二次再次获取锁。当前线程拿到锁后对象会被锁定,再次获取锁就会CAS失败,而如果对象被锁定时Mark Word指向当前线程的栈帧,那么就可以认为是线程重入锁了,可以再次拿到锁执行

轻量级锁解锁

如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程

重量级锁

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现

例如

private static void test(char[] str) {
    synchronized (Object.class) {
        str[0] = '/';
    }
}

上述代码的字节码为

在这里插入图片描述

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit(方法正常结束和异常结束都要配对)与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态

根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当无条件被阻塞等待,直到请求锁定的对象被持有它的线程释放为止

为什么重量级锁很“重”?

在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。而使用重量级锁时只要获取对象锁失败会无条件直接阻塞当前线程,那么反复的进行用户态和核心态的转换会是比较大的一笔开销

如下为锁状态转化Mark Word变化 在这里插入图片描述

如下为锁状态流程图

在这里插入图片描述

synchronized的优化处理

在Java 1.6中除了对锁进行了四个状态的优化,还进行了一些细节上的优化,也是非常重要的

自旋锁与自适应自旋

自旋等待不能代替阻塞,首先我们要清楚的是自旋等待的线程是没有挂起的,也就是线程还在运行中,运行的线程是需要占用一个处理器并且处理器需要不断做自旋空转的工作,且先不说对处理器数量的要求(至少要有两个处理器吧),自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。

不过无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的,这是比较死板且不灵活的。在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持),如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。简而言之,系统监测到一段加锁的代码不可能产生竞争就把这个锁删去了

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如下所示连续的append()方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,三个append方法都是对一个buffer对象加锁和解锁,其实只需要在第一个append之前加锁和最后一个append之后加锁即可

public String concatString1(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1); // 加锁,解锁
    sb.append(s2); // 加锁,解锁
    sb.append(s3); // 加锁,解锁
    return sb.toString();
}

public String concatString2(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    // 加锁
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    // 解锁
    return sb.toString();
}

如果您觉得其中有用,欢迎点赞、留言并分享给更多人。感谢您的支持!