Java并发系列学习(三)

Java并发系列学习(三)

众所周知, Java并发系列编程一直都是Java程序员难以轻易绕过的山,可谓之小高之山也。Java生态圈中提供了非常丰富的并发编程类库,但是这样子也造就了非常多的人知其然而不知其所以然,很多人只会用,却不知其底层的运行机制,不知其优势与缺陷,也就无法将其融会贯通,做到信手拈来。何况,即便非常完善的类库也无法满足所有的业务需求,适当的时候我们可能要自己编写类库来支撑自己的业务。在这个系列中,我们一起来深入学习并发编程,一起成长。本人能力有限,若有错误,请及时指正。

聊聊synchronized关键字

在JDK1.6 以前,synchronized关键字被我们认为是非常重的锁,相较之Lock,性能让人闻风丧胆。但是随着JDK1.6 之后对synchronized进行了各种优化,比如锁消除,锁粗化等等,让synchronized的性能基本与Lock相近。本文尝试剖析底层实现机制,并了解JDK1.6版本锁带来的各种优化手段,期望能够让我们在面对同步问题之时,可以知其所以然。

在了解内部原理之前,我们先看一段代码

public class SynchronizedTest {
    
    public synchronized void synMethod(){
        
    }
    
    public void synBlock() {
        synchronized (this) {
            
        }
    }
}

这段代码是我们对synchronized关键字用的最多的写法,也是synchronized的表现形式,目前我们最常见的表现形式如下:

  • synchronized method 表示锁定当前的实例对象
  • synchronized static method 表示锁定当前的class对象
  • synchronized() 表示锁定括号内的对象(实例对象/class对象)

除开这些表现形式之外,我们通过命令行的方式输入javap -v SynchronizedTest.class可以看到class文件的编译信息,如下图,可以看到同步代码块的具体实现:monitorentermonitorexit。monitorenter指令插入到同步代码块的开始位置,表示执行临界区资源时需要获取monitor锁。monitorexit指令插入到同步代码块的结束位置,表示退出临界区并释放锁资源。JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,将处于锁定状态。

监视器锁.png

实现原理

synchronized的实现原理是从获取monitor锁到释放的过程。具体的执行机制,可以通过一张图来展示:

synchronzied执行机制.png

整个执行过程非常清晰,当一个线程尝试获取monitor锁时,如果monitorenter成功,则表示当前对象锁已被该线程持有。如果该线程还未释放锁,而其他线程又尝试获取该锁时,线程会进入同步队列中等待已经持有锁的线程释放后再继续与其他线程竞争。除此之外,这里还用虚线表示,如果线程已持有该对象锁,但是发现执行过程中,还有未满足条件时,那么当前线程需要让出该锁,并等待重新竞争该锁。流程则是:线程调用wait时,线程会直接走到monitorexit,释放持有的锁,然后进入等待队列中,只有当线程被notify/notifyall调用时被唤醒,唤醒之后线程会进入同步队列中,与其他线程继续竞争锁资源。

文章开头提到,Java对象本身就是锁,查看JDK源码也可以发现,wait/notify/notifyall均是Object下的方法,对于整个Java体系而言,Object作为根类存在,所以Java任何对象也拥有该系列方法,测面也印证了Java对象本身就是锁。

从synchronized的实现原理来看,该关键字非常重,所以JDK1.6之后做了非常多的优化,那么这些优化都有哪些,又是如何优化的呢?

锁优化

在讨论锁优化之前,有必要优先了解一下Java对象头。

HotSpot虚拟机的对象头主要包括两部分信息:

  • MarkWord ,第一部分MarkWord,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。
  • klass ,对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.
  • 数组长度 (数组对象),如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。

MarkWord 数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 执行重量级锁定的指针
GC标记 11 空(不需要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄

TIP: jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。但是这些优化都不是绝对的优化意义,是否需要这些优化,要根据业务而定,但是了解这些优化技术以及优化方式,有助于我们通过具体业务去决定是否开启某些优化项。

锁粗化

原则上,我们尽可能将同步块的作用范围限制的尽量小——只在共享数据操作部分进行同步。但是在某些场景下,一系列的连续操作都对同一个对象进行反复加锁与解锁,甚至加锁操作时出现在循环体中的,那即时没有现成竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗,这个时候就需要锁粗化,将锁的范围扩大至整个连续操作的序列。(比如StringBuffer的append操作)

自旋锁

当多个线程同时访问共享的数据并可以在短时间内处理完成,这个时候去挂起和恢复线程是不值得。所以自旋锁的本质就是:当一个线程在等待持有锁的线程执行完成的时候,自行发起一个忙循环等待当前持有锁的线程执行完成。在JDK1.6之后自旋锁设置自动开启。但是这里涉及到一个问题就是自旋的时间(自旋的次数),如果线程自旋超过了限定的次数之后,就有必要交给传统的互斥方式将线程挂起。虚拟机默认是自旋次数为10,用户可以通过使用参数-XX:PreBlockSpin来更改。

自适应锁

自适应锁的语义是,自旋锁的时间由前一次在同一个锁上的自旋时间,自旋次数以及所的拥有者的状态来决定。

锁消除

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

偏向锁

偏向锁在JDK1.6引入,目的是消除数据无竞争情况下的同步原语,进一步提高程序的性能。偏向锁在无竞争的情况下把整个同步原语都消除掉。

  • 偏向锁获取

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来解锁和解锁。只需要简单的测试一下对象的MarkWord里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要在测试一下MarkWord中偏向锁的标识是否为1:如果没有设置为1,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

  • 偏向锁释放

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

  • 偏向锁关闭

偏向锁可以提高带有同步但无竞争的程序性能,它是一个带有权衡效益性质的优化,如果程序大多数锁都被会被多线程访问,那偏向锁模式就是多余的,-XX:UseBiasedLocking可以禁止偏向锁的优化。

轻量级锁

轻量级锁是从JDK1.6之后加入的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的损耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

TIP:轻量级锁在有竞争的条件下,比重量级锁更慢。

轻量级锁加锁以及膨胀流程,如下图:

轻量级锁.png

重量级锁

通过内置锁Monitor实现(监视器锁),Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

参考资料

《深入了解Java虚拟机》
《Java 并发编程的艺术》
《死磕Java并发》

你可能感兴趣的:(Java并发系列学习(三))