Java并发编程之synchronized锁优化

个人博客请访问 http://www.x0100.top          

1. 为什么需要优化?

synchronized监视器锁在互斥同步上对性能的影响很大。

Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,状态转换需要花费很多的处理器时间。

所以频繁的通过Synchronized实现同步会严重影响到程序效率,这种锁机制也被称为重量级锁,为了减少重量级锁带来的性能开销,JDK对Synchronized进行了种种优化。

2. 自旋锁和适应自旋锁

大多数情况下,线程持有锁的时间都不会太长,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

1)自旋锁

当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。
在经过若干次循环后,如果得到锁,就顺利进入临界区;如果还不能获得锁,那就会将线程在操作系统层面挂起。

2)自旋锁和阻塞最大的区别

主要区别:是不是放弃处理器的执行时间。

阻塞放弃了CPU时间,进入了等待区,等待被唤醒。响应慢。自旋锁一直占用CPU时间,时刻检查共享资源是否可以被访问,所以响应速度更快。

3)缺点

如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好。但是如果持有锁的线程占用锁时间较长,等待锁的线程自旋一定次数后还是拿不到锁而被阻塞,那么自旋就白白浪费了CPU的资源。
所以自旋的次数直接决定了自旋锁的性能。JDK自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

4)自适应自旋锁

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。

3. 锁消除

如果JVM检测到某段代码不可能存在共享数据竞争,JVM会对这段代码的同步锁进行锁消除。

在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。

举例:

public void vectorTest() {
    Vector<String> vector = new Vector<String>();
    for (int i = 0; i < 10; i++) {
        vector.add(i + "");
    }

    System.out.println(vector);
}

 

Vector的add方法是Synchronized修饰的。

在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。

4. 锁粗化

很多时候,我们提倡尽量减小锁的粒度,可以避免不必要的阻塞。 让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是如果在一段代码中连续的用同一个监视器锁反复的加锁解锁,甚至加锁操作出现在循环体中的时候,就会导致不必要的性能损耗,这种情况就需要锁粗化。

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

举例:

for(int i=0;i<100000;i++){
    synchronized(this){
        do();
}

会被粗化成:

synchronized(this){
    for(int i=0;i<100000;i++){
        do();
}

5. 知识补充:Java对象头

对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

普通对象的对象头包括两部分:Mark Word和Class Metadata Address (类型指针),如果是数组对象还包括一个额外的Array length数组长度部分。

Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。

Class Metadata Address:类型指针指向对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。

Mark Word

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

对mark word的设计方式上,非常像网络协议报文头:将mark word划分为多个比特位区间,并在不同的对象状态下赋予比特位不同的含义。

下图描述了在32位虚拟机上,在对象不同状态时mark word各个比特位区间的含义。

对象头——《Java并发编程艺术》

6. 偏向锁、轻量级锁、重量级锁

从Java对象头的Mark word中可以看到,synchronized锁一共具有四种状态:无锁、偏向锁、轻量级锁、重量级锁。

偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

偏向锁

目的:大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,所以引入偏向锁让线程获得锁的代价更低。

偏向锁认为环境中不存在竞争情况,锁只被一个线程持有,一旦有不同的线程获取或竞争锁对象,偏向锁就升级为轻量级锁。

偏向锁在无多线程竞争的情况下可以减少不必须要的轻量级锁执行路径。

轻量级锁

目的:在大多数情况下同步块并不会出现竞争情况,大部分情况是不同线程交替持有锁,所以引入轻量级锁可以减少重量级锁对线程的阻塞带来的开销。

轻量级锁认为环境中线程几乎没有对锁对象的竞争,即使有竞争也只需要稍微等待(自旋)下就可以获取锁,但是自旋次数有限制,如果超过该次数,则会升级为重量级锁。

重量级锁

监视器锁Monitor

7. 锁的膨胀过程

synchronized锁膨胀过程就是无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程。这个过程是随着多线程对锁的竞争越来越激烈,锁逐渐升级膨胀的过程。

如下分析,从一个没有线程访问的锁逐渐升级到重量级锁的过程:

1)一个锁对象刚刚开始创建的时候,没有任何线程来访问它,此时线程状态为无锁状态。Mark word(锁标志位-01 是否偏向-0)

2)当线程A来访问这个对象锁时,它会偏向这个线程A。线程A检查Mark word(锁标志位-01 是否偏向-0)为无锁状态。此时,有线程访问锁了,无锁升级为偏向锁,Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID)

3)当线程A执行完同步块时,不会主动释放偏向锁。持有偏向锁的线程执行完同步代码后不会主动释放偏向锁,而是等待其他线程来竞争才会释放锁。Mark word不变(锁标志位-01,是否偏向-1,线程ID-线程A的ID)

4)当线程A再次获取这个对象锁时,检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A,可以直接执行同步代码。这样偏向锁保证了总是同一个线程多次获取锁的情况下,每次只需要检查标志位就行,效率很高

5)当线程A执行完同步块之后,线程B获取这个对象锁 检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A。有不同的线程获取锁对象,偏向锁升级为轻量级锁,并由线程B获取该锁。

6)当线程A正在执行同步块时,也就是正持有偏向锁时,线程B获取来这个对象锁。

检查Mark word(锁标志位-01,是否偏向-1,线程ID-线程A的ID),偏向锁且偏向线程A。

线程A撤销偏向锁:

  1. 等到全局安全点执行撤销偏向锁,暂停持有偏向锁的线程A并检查程A的状态;

  2. 如果线程A不处于活动状态或者已经退出同步代码块,则将对象锁设置为无锁状态,然后再升级为轻量级锁。由线程B获取轻量级锁。

  3. 如果线程A还在执行同步代码块,也就是线程A还需要这个对象锁,则偏向锁膨胀为轻量级锁。

线程A膨胀为轻量级锁过程:

  1. 在升级为轻量级锁之前,持有偏向锁的线程(线程A)是暂停的

  2. 线程A栈帧中创建一个名为锁记录的空间(Lock Record)

  3. 锁对象头中的Mark Word拷贝到线程A的锁记录中

  4. Mark Word的锁标志位变为00,指向锁记录的指针指向线程A的锁记录地址,Mark word(锁标志位-00,其他位-线程A锁记录的指针)

  5. 当原持有偏向锁的线程(线程A)获取轻量级锁后,JVM唤醒线程A,线程A执行同步代码块

7)线程A持有轻量级锁,线程A执行完同步块代码之后,一直没有线程来竞争对象锁,正常释放轻量级锁。释放轻量级锁操作:CAS操作将线程A的锁记录(Lock Record)中的Mark Word替换回锁对象头中。

8)线程A持有轻量级锁,执行同步块代码过程中,线程B来竞争对象锁。
Mark word(锁标志位-00,其他位-线程A锁记录的指针)

  1. 线程B会先在栈帧中建立锁记录,存储锁对象目前的Mark Word的拷贝

  2. 线程B通过CAS操作尝试将锁对象的Mark Word的指针指向线程B的Lock Record,如果成功,说明线程A刚刚释放锁,线程B竞争到锁,则执行同步代码块。

  3. 因为线程A一直持有锁,大部分情况下CAS是会失败的。CAS失败之后,线程B尝试使用自旋的方式来等待持有轻量级锁的线程释放锁。

  4. 线程B不会一直自旋下去,如果自旋了一定次数后还是失败,线程B会被阻塞,等待释放锁后唤醒。此时轻量级锁就会膨胀为重量级锁。Mark word(锁标志位-10,其他位-重量级锁monitor的指针)

  5. 线程A执行完同步块代码之后,执行释放锁操作,CAS 操作将线程A的锁记录(Lock Record)中的Mark Word 替换回锁对象对象头中,因为对象头中已经不是原来的轻量级锁的指针了,而是重量级锁的指针,所以CAS操作会失败。

  6. 释放轻量级锁CAS操作替换失败之后,需要在释放锁的同时需要唤醒被挂起的线程B。线程B被唤醒,获取重量级锁monitor

总结

synchronized实现同步会严重影响到程序效率,为了减少重量级锁带来的性能开销,JDK对Synchronized进行了优化。

自旋锁:当锁被占用时,当前想要获取锁的线程不会被立即挂起,而是做几个空循环,看持有锁的线程是否会很快释放锁。如果此时锁释放,当前线程就可以获得锁。

锁消除:如果JVM检测到某段代码不可能存在共享数据竞争,会对这段代码的同步锁进行锁消除。

锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

synchronized锁膨胀过程就是无锁 → 偏向锁 → 轻量级锁 → 重量级锁的一个过程。这个过程是随着多线程对锁的竞争越来越激烈,锁逐渐升级膨胀的过程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章