文章已收录Github精选,欢迎Starhttps://github.com/yehongzhi

前言

作为Java程序员,我们都知道在多线程的情况下,为了保证线程安全,经常会使用synchronized和Lock锁。Lock锁之前写过一篇《不得不学的AQS》,已经详细讲解过Lock锁的底层原理。这次我们讲一下日常开发中常用的关键字synchronized,想要用得好,底层原理必须要搞明白。

synchronized是JDK自带的一个关键字,在JDK1.5之前是一个重量级锁,所以从性能上考虑大部分人会选择Lock锁,不过毕竟是JDK自带的关键字,所以在JDK1.6后对它进行优化,引入了偏向锁,轻量级锁,自旋锁等概念。

一、synchronized的使用方式

在语法上,要使用synchronized关键字,需要把任意一个非null对象作为”锁”对象,也就是需要一个对象监视器(Object Monitor)。总的来说有三种用法:

1.1 作用在实例方法

修饰实例方法,相当于对当前实例对象this加锁,this作为对象监视器。

public synchronized void hello(){
    System.out.println("hello world");
}

1.2 作用在静态方法

修饰静态方法,相当于对当前类的Class对象加锁,当前类的Class对象作为对象监视器。

public synchronized static void helloStatic(){
    System.out.println("hello world static");
}

1.3 修饰代码块

指定加锁对象,对给定对象加锁,括号括起来的对象就是对象监视器。

public void test(){
    SynchronizedTest test = new SynchronizedTest();        
    synchronized (test){
        System.out.println("hello world");
    }
}

二、synchronized锁的原理

在讲原理前,我们先讲一下Java对象的构成。在JVM中,对象在内存中分为三块区域:对象头,实例数据和对齐填充。如图所示:

对象头

  • Mark Word,用于存储对象自身运行时的数据,如哈希码(Hash Code),GC分代年龄,锁状态标志,偏向线程ID、偏向时间戳等信息,它会根据对象的状态复用自己的存储空间。它是实现轻量级锁和偏向锁的关键
  • 类型指针,对象会指向它的类的元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例。
  • Array length,如果对象是一个数组,还必须记录数组长度的数据。

实例数据

  • 存放类的属性数据信息,包括父类的属性信息。

对齐填充

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

2.1 同步代码块原理

为了看底层实现原理,使用javap -v xxx.class命令进行反编译。

这是使用同步代码块被标志的地方就是刚刚提到的对象头,它会关联一个monitor对象,也就是括号括起来的对象。

1、monitorenter,如果当前monitor的进入数为0时,线程就会进入monitor,并且把进入数+1,那么该线程就是monitor的拥有者(owner)。

2、如果该线程已经是monitor的拥有者,又重新进入,就会把进入数再次+1。也就是可重入的。

3、monitorexit,执行monitorexit的线程必须是monitor的拥有者,指令执行后,monitor的进入数减1,如果减1后进入数为0,则该线程会退出monitor。其他被阻塞的线程就可以尝试去获取monitor的所有权。

monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

总的来说,synchronized的底层原理是通过monitor对象来完成的。

2.2 同步方法原理

比如说使用synchronized修饰的实例方法。

public synchronized void hello(){
    System.out.println("hello world");
}

同理使用javap -v反编译。

可以看到多了一个标志位ACC_SYNCHRONIZED,作用就是一旦执行到这个方法时,就会先判断是否有标志位,如果有这个标志位,就会先尝试获取monitor,获取成功才能执行方法,方法执行完成后再释放monitor。在方法执行期间,其他线程都无法获取同一个monitor。归根结底还是对monitor对象的争夺,只是同步方法是一种隐式的方式来实现。

2.3 Monitor

上面经常提到monitor,它内置在每一个对象中,任何一个对象都有一个monitor与之关联,synchronized在JVM里的实现就是基于进入和退出monitor来实现的,底层则是通过成对的MonitorEnter和MonitorExit指令来实现,因此每一个Java对象都有成为Monitor的潜质。所以我们可以理解monitor是一个同步工具。

三、synchronized锁的优化

前面讲过JDK1.5之前,synchronized是属于重量级锁,重量级需要依赖于底层操作系统的Mutex Lock实现,然后操作系统需要切换用户态和内核态,这种切换的消耗非常大,所以性能相对来说并不好。

既然我们都知道性能不好,JDK的开发人员肯定也是知道的,于是在JDK1.6后开始对synchronized进行优化,增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。锁的等级从无锁,偏向锁,轻量级锁,重量级锁逐步升级,并且是单向的,不会出现锁的降级。

3.1 自适应性自旋锁

在说自适应自旋锁之前,先讲自旋锁。上面已经讲过,当线程没有获得monitor对象的所有权时,就会进入阻塞,当持有锁的线程释放了锁,当前线程才可以再去竞争锁,但是如果按照这样的规则,就会浪费大量的性能在阻塞和唤醒的切换上,特别是线程占用锁的时间很短的话。

为了避免阻塞和唤醒的切换,在没有获得锁的时候就不进入阻塞,而是不断地循环检测锁是否被释放,这就是自旋。在占用锁的时间短的情况下,自旋锁表现的性能是很高的。

但是又有问题,由于线程是一直在循环检测锁的状态,就会占用cpu资源,如果线程占用锁的时间比较长,那么自旋的次数就会变多,占用cpu时间变长导致性能变差,当然我们也可以通过参数-XX:PreBlockSpin设置自旋锁的自旋次数,当自旋一定的次数(时间)后就挂起,但是设置的自旋次数是多少比较合适呢?

如果设置次数少了或者多了都会导致性能受到影响,而且占用锁的时间在业务高峰期和正常时期也有区别,所以在JDK1.6引入了自适应性自旋锁。

自适应性自旋锁的意思是,自旋的次数不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

表现是如果此次自旋成功了,很有可能下一次也能成功,于是允许自旋的次数就会更多,反过来说,如果很少有线程能够自旋成功,很有可能下一次也是失败,则自旋次数就更少。这样能最大化利用资源,随着程序运行和性能监控信息的不断完善,虚拟机对锁的状况预测会越来越准确,也就变得越来越智能。

3.2 锁消除

锁消除是一种锁的优化策略,这种优化更加彻底,在JVM编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。这种优化策略可以消除没有必要的锁,节省毫无意义的请求锁时间。比如StringBuffer的append()方法,就是使用synchronized进行加锁的。

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

如果在实例方法中StringBuffer作为局部变量使用append()方法,StringBuffer是不可能存在共享资源竞争的,因此会自动将其锁消除。例如:

public String add(String s1, String s2) {
    //sb属于不可能共享的资源,JVM会自动消除内部的锁
    StringBuffer sb = new StringBuffer();
    sb.append(s1).append(s2);
    return sb.toString();
}

3.3 锁粗化

如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。意思是将多个连续加锁、解锁的操作连接在一起,扩展成为一个范围更大的锁。

3.4 偏向锁

偏向锁是JDK1.6引入的一个重要的概念,JDK的开发人员经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。也就是说在很多时候我们是假设有多线程的场景,但是实际上却是单线程的。所以偏向锁是在单线程执行代码块时使用的机制。

原理是什么呢,我们前面提到锁的争夺实际上是Monitor对象的争夺,还有每个对象都有一个对象头,对象头是由Mark Word和Klass pointer 组成的。一旦有线程持有了这个锁对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中,当同一个线程再次进入时,就不再进行同步操作,这样就省去了大量的锁申请的操作,从而提高了性能。

一旦有多个线程开始竞争锁的话呢?那么偏向锁并不会一下子升级为重量级锁,而是先升级为轻量级锁。

3.5 轻量级锁

如果获取偏向锁失败,也就是有多个线程竞争锁的话,就会升级为JDK1.6引入的轻量级锁,Mark Word 的结构也变为轻量级锁的结构。

执行同步代码块之前,JVM会在线程的栈帧中创建一个锁记录(Lock Record),并将Mark Word拷贝复制到锁记录中。然后尝试通过CAS操作将Mark Word中的锁记录的指针,指向创建的Lock Record。如果成功表示获取锁状态成功,如果失败,则进入自旋获取锁状态。

自旋锁的原理在上面已经讲过了,如果自旋获取锁也失败了,则升级为重量级锁,也就是把线程阻塞起来,等待唤醒。

3.6 重量级锁

重量级锁就是一个悲观锁了,但是其实不是最坏的锁,因为升级到重量级锁,是因为线程占用锁的时间长(自旋获取失败),锁竞争激烈的场景,在这种情况下,让线程进入阻塞状态,进入阻塞队列,能减少cpu消耗。所以说在不同的场景使用最佳的解决方案才是最好的技术。synchronized在不同的场景会自动选择不同的锁,这样一个升级锁的策略就体现出了这点。

3.7 小结

偏向锁:适用于单线程执行。

轻量级锁:适用于锁竞争较不激烈的情况。

重量级锁:适用于锁竞争激烈的情况。

四、Lock锁与synchronized的区别

我们看一下他们的区别:

  • synchronized是Java语法的一个关键字,加锁的过程是在JVM底层进行。Lock是一个类,是JDK应用层面的,在JUC包里有丰富的API。
  • synchronized在加锁和解锁操作上都是自动完成的,Lock锁需要我们手动加锁和解锁。
  • Lock锁有丰富的API能知道线程是否获取锁成功,而synchronized不能。
  • synchronized能修饰方法和代码块,Lock锁只能锁住代码块。
  • Lock锁有丰富的API,可根据不同的场景,在使用上更加灵活。
  • synchronized是非公平锁,而Lock锁既有非公平锁也有公平锁,可以由开发者通过参数控制。

个人觉得在锁竞争不是很激烈的场景,使用synchronized,语义清晰,实现简单,JDK1.6后引入了偏向锁,轻量级锁等概念后,性能也能保证。而在锁竞争激烈,复杂的场景下,则使用Lock锁会更灵活一点,性能也较稳定。

总结

学习synchronized关键字的底层原理不是钻牛角尖,其实是从底层原理上知道了synchronized在什么场景使用会有什么样的效果,我们都知道没有最好的技术,只有最适合的技术,所以在学完之后,希望对大家有所帮助,写出更加高效的代码。所谓不积跬步无以至千里,一步一个脚印,哪怕现在还是菜鸟,总有一天也会成为雄鹰。

那么这篇文章就写到这里了,感谢大家的阅读,希望看完后能有所收获!

觉得有用就点个赞吧,你的点赞是我创作的最大动力~

我是一个努力让大家记住的程序员。我们下期再见!!!

能力有限,如果有什么错误或者不当之处,请大家批评指正,一起学习交流!



并发编程

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!