从底层分析synchronied和ReentrantLock区别

原创
10/05 15:52
阅读数 6.5K

Java 中常见的两种加锁的方式是:一,常见的synchronized 关键字;二,就是使用 concurrent 包里面的 Lock。

针对这两种锁,JDK 自身做了很多的优化,它们的实现方式也是不同的。下面分析这两种锁的底层原理,看一下对锁的一些优化方式。

synchronied

synchronized 关键字给代码或者方法上锁时,都有显示或者隐藏的上锁对象。当一个线程试图访问同步代码块时,它首先必须得到锁,而退出或抛出异常时必须释放锁。

给普通方法加锁时,上锁的对象是 this;
给静态方法加锁时,锁的是 class 对象;
给代码块加锁,可以指定一个具体的对象作为锁。

synchronied使用了class对象的minitor监视器,下面部分内容来源于网络 1.monitor 原理

synchronized 在字节码中,是怎么体现的呢? 参照下面的代码,在命令行执行 javac,然后再执行 javap -v -p,就可以看到它具体的字节码。

可以看到,在字节码的体现上,它只给方法加了一个 flag:ACC_SYNCHRONIZED。

复制 synchronized void syncMethod() { System.out.println("syncMethod"); } ======字节码===== synchronized void syncMethod();    descriptor: ()V    flags: ACC_SYNCHRONIZED    Code:      stack=2, locals=1, args_size=1         0: getstatic     #4         3: ldc           #5         5: invokevirtual #6         8: return 我们再来看下同步代码块的字节码。可以看到,字节码是通过 monitorenter 和monitorexit 两个指令进行控制的。

复制 void syncBlock(){    synchronized (Test.class){   } } ======字节码====== void syncBlock();    descriptor: ()V    flags:    Code:      stack=2, locals=3, args_size=1         0: ldc           #2         2: dup         3: astore_1         4: monitorenter         5: aload_1         6: monitorexit         7: goto          15        10: astore_2        11: aload_1        12: monitorexit        13: aload_2        14: athrow        15: return      Exception table:         from    to  target type             5     7    10   any            10    13    10   any 这两者虽然显示效果不同,但他们都是通过 monitor 来实现同步的。我们可以通过下面这张图,来看一下 monitor 的原理。

注意了,下面是面试题目高发地。比如,你能描述一下 monitor 锁的实现原理吗?

如上图所示,我们可以把运行时的对象锁抽象地分成三部分。其中,EntrySet 和 WaitSet 是两个队列,中间虚线部分是当前持有锁的线程,我们可以想象一下线程的执行过程。

当第一个线程到来时,发现并没有线程持有对象锁,它会直接成为活动线程,进入 RUNNING 状态。

接着又来了三个线程,要争抢对象锁。此时,这三个线程发现锁已经被占用了,就先进入 EntrySet 缓存起来,进入 BLOCKED 状态。此时,从 jstack 命令,可以看到他们展示的信息都是 waiting for monitor entry。

复制 "http-nio-8084-exec-120" #143 daemon prio=5 os_prio=31 cpu=122.86ms elapsed=317.88s tid=0x00007fedd8381000 nid=0x1af03 waiting for monitor entry [0x00007000150e1000]   java.lang.Thread.State: BLOCKED (on object monitor)   at java.io.BufferedInputStream.read(java.base@13.0.1/BufferedInputStream.java:263)    - waiting to lock <0x0000000782e1b590> (a java.io.BufferedInputStream)   at org.apache.commons.httpclient.HttpParser.readRawLine(HttpParser.java:78)   at org.apache.commons.httpclient.HttpParser.readLine(HttpParser.java:106)   at org.apache.commons.httpclient.HttpConnection.readLine(HttpConnection.java:1116)   at org.apache.commons.httpclient.HttpMethodBase.readStatusLine(HttpMethodBase.java:1973)   at org.apache.commons.httpclient.HttpMethodBase.readResponse(HttpMethodBase.java:1735) 处于活动状态的线程,执行完毕退出了;或者由于某种原因执行了 wait 方法,释放了对象锁,进入了 WaitSet 队列,这就是在调用 wait 之前,需要先获得对象锁的原因。

就像下面的代码:

复制 synchronized (lock){    try {         lock.wait();   } catch (InterruptedException e) {        e.printStackTrace();   } } 此时,jstack 显示的线程状态是 WAITING 状态,而原因是 in Object.wait()。

复制 "wait-demo" #12 prio=5 os_prio=31 cpu=0.14ms elapsed=12.58s tid=0x00007fb66609e000 nid=0x6103 in Object.wait() [0x000070000f2bd000]   java.lang.Thread.State: WAITING (on object monitor)   at java.lang.Object.wait(java.base@13.0.1/Native Method)    - waiting on <0x0000000787b48300> (a java.lang.Object)   at java.lang.Object.wait(java.base@13.0.1/Object.java:326)   at WaitDemo.lambda$main$0(WaitDemo.java:7)    - locked <0x0000000787b48300> (a java.lang.Object)   at WaitDemo$$Lambda$14/0x0000000800b44840.run(Unknown Source)   at java.lang.Thread.run(java.base@13.0.1/Thread.java:830) 发生了这两种情况,都会造成对象锁的释放,进而导致 EntrySet 里的线程重新争抢对象锁,成功抢到锁的线程成为活动线程,这是一个循环的过程。

那 WaitSet 中的线程是如何再次被激活的呢?接下来,在某个地方,执行了锁的 notify 或者 notifyAll 命令,会造成 WaitSet 中的线程,转移到 EntrySet 中,重新进行锁的争夺。

如此周而复始,线程就可按顺序排队执行。

分级锁

在 JDK 1.8 中,synchronized 的速度有了明显的提升,使用了关键性的锁分级技术 JVM 会根据使用情况,对 synchronized 的锁,进行升级,偏向锁 — 轻量级锁 — 重量级锁。 锁只能升级,不能降级,所以一旦升级为重量级锁,就只能依靠操作系统进行调度。

先看一下对象在内存里的结构。

如上图所示,对象分为 MarkWord、Class Pointer、Instance Data、Padding 四个部分。

和锁升级关系最大的就是 MarkWord,它的长度是 24 位,我们着重介绍一下。它包含Thread ID(23bit)、Age(6bit)、Biased(1bit)、Tag(2bit) 四个部分,锁升级就是靠判断 Thread Id、Biased、Tag 等三个变量值来进行的。

偏向锁

在只有一个线程使用了锁的情况下,偏向锁能够保证更高的效率。

具体过程是这样的:当第一个线程第一次访问同步块时,会先检测对象头 Mark Word 中的标志位 Tag 是否为 01,以此判断此时对象锁是否处于无锁状态或者偏向锁状态(匿名偏向锁)。

01 也是锁默认的状态,线程一旦获取了这把锁,就会把自己的线程 ID 写到 MarkWord 中,在其他线程来获取这把锁之前,锁都处于偏向锁状态。

当下一个线程参与到偏向锁竞争时,会先判断 MarkWord 中保存的线程 ID 是否与这个线程 ID 相等,如果不相等,会立即撤销偏向锁,升级为轻量级锁。

轻量级锁

轻量级锁的获取是怎么进行的呢?它们使用的是自旋方式。

参与竞争的每个线程,会在自己的线程栈中生成一个 LockRecord ( LR ),然后每个线程通过 CAS(自旋)的方式,将锁对象头中的 MarkWord 设置为指向自己的 LR 的指针,哪个线程设置成功,就意味着哪个线程获得锁。

当锁处于轻量级锁的状态时,就不能够再通过简单地对比 Tag 的值进行判断,每次对锁的获取,都需要通过自旋。

当然,自旋也是面向不存在锁竞争的场景,比如一个线程运行完了,另外一个线程去获取这把锁;但如果自旋失败达到一定的次数,锁就会膨胀为重量级锁。

重量级锁

重量级锁,即我们对 synchronized 的直观认识,这种情况下,线程会挂起,进入到操作系统内核态,等待操作系统的调度,然后再映射回用户态。系统调用是昂贵的,所以重量级锁的名称由此而来。

如果系统的共享变量竞争非常激烈,锁会迅速膨胀到重量级锁,这些优化就名存实亡。如果并发非常严重,可以通过参数 -XX:-UseBiasedLocking 禁用偏向锁,理论上会有一些性能提升,但实际上并不确定。

ReentrantLock

concurrent包里,有两个类 ReentrantLock 和 ReentrantReadWriteLock ,Reentrant就是可重入的意思,意思是指在线程运行时,可以多次获取同一个对象锁。 Lock 是基于 AQS(AbstractQueuedSynchronizer)实现的,而 AQS 是基于 volitale 和 CAS 实现的。 AQS,全称AbstractQueuedSynchronizer,又称为队列同步器; 它是用来构建锁或其他同步组件的基础框架,内部通过一个int类型的成员变量state来控制同步状态,当state=0时,则说明没有任何线程占有共享资源的锁,当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待,此state使用volitale关键字修饰,并且在修改是使用cas

AQS内部通过内部类Node构成FIFO的同步队列来完成线程获取锁的排队工作,同时利用内部类ConditionObject构建等待队列,当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中进行锁竞争。注意这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的后将加入同步队列等待,而另一种则是等待队列(可有多个),通过Condition调用await()方法释放锁后,将加入等待队列

AQS同步器的实现依赖于内部的同步队列(FIFO的双向链表对列)完成对同步状态(state)的管理,当前线程获取锁(同步状态)失败时,AQS会将该线程以及相关等待信息包装成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会将头结点head中的线程唤醒,让其尝试获取同步状态。

获取锁时,首先对同步状态执行CAS操作,尝试把state的状态从0设置为1,如果返回true则代表获取同步状态成功,也就是当前线程获取锁成,可操作临界资源,如果返回false,则表示已有线程持有该同步状态(其值为1),获取锁失败,注意这里存在并发的情景,也就是可能同时存在多个线程设置state变量,因此是CAS操作保证了state变量操作的原子性。

读写锁

但对于有些业务来说,使用 Lock 这种粗粒度的锁还是太慢了。比如,对于一个HashMap 来说,某个业务是读多写少的场景,这个时候,如果给读操作,也加上和写操作一样的锁的话,效率就会很慢。

ReentrantReadWriteLock 是一种读写分离的锁,它允许多个读线程同时进行,但读和写、写和写是互斥的。

同样读写锁也是基于AQS的同步器,并维护state字段, 那么同一个同步器,如果同时表示读锁和写锁呢,此时查看源码得知,它是在state字段上做文章,state是int类型,有32位,其中高32位表示读锁,底32位表示写锁,这样一个简单的区分,就实现了读写锁的功能;

公平锁与非公平锁

我们平常用到的锁都是非公平锁,如前面提到的monitor,当持有锁的线程释放锁的时候,所有逇线程就会争抢这把锁,这个争抢过程,是随机的,也就是说你并不知道哪个线程会获取对象锁,谁抢到了就算谁的。 如果某个线程一直获取不到锁,就会产生饥饿状态,这就叫线程饥饿。

而公平锁通过把随机变成有序,可以解决这个问题,synchronized 没有这个功能,在Lock 中可以通过构造参数设置成公平锁 由于所有的线程都需要排队,需要在多核的场景下维护一个同步队列,在多个线程争抢锁的时候,吞吐量就很低。

下面是 20 个并发之下,锁的 JMH 测试结果,可以看到,非公平锁比公平锁的性能高出两个数量级。

锁的优化技巧

死锁

手写死锁代码:

public class DeadLockDemo {    public static void main(String[] args) {        Object object1 = new Object();        Object object2 = new Object();        Thread t1 = new Thread(() -> {            synchronized (object1) {                try {                    Thread.sleep(200);               } catch (InterruptedException e) {                    e.printStackTrace();               }                synchronized (object2) {               }           }       }, "deadlock-demo-1");

       t1.start();        Thread t2 = new Thread(() -> {            synchronized (object2) { try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); }                synchronized (object1) {               }           }       }, "deadlock-demo-2");        t2.start();   } }

代码创建了两把对象锁,线程1 首先拿到了 object1 的对象锁,200ms 后尝试获取 object2 的对象锁。但这个时候,object2 的对象锁已经被线程2 获取了。这两个线程进入了相互等待的状态,产生了死锁。

产生死锁的条件: 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。 环路等待条件:在发生死锁时,必然存在一个进程--资源的环形链。

破坏产生死锁的条件即可以避免死锁 如带超时时间的 tryLock 方法,有一方超时让步,可以一定程度上避免死锁。

锁的优化理论其实很简单,那就是减少锁的冲突。无论是锁的读写分离,还是分段锁,本质上都是为了避免多个线程同时获取同一把锁。

所以我们可以总结一下优化的一般思路:减少锁的粒度、减少锁持有的时间、锁分级、锁分离 、锁消除、乐观锁、无锁等

减少锁粒度

通过减小锁的粒度,可以将冲突分散,减少冲突的可能,从而提高并发量。简单来说,就是把资源进行抽象,针对每类资源使用单独的锁进行保护。

减少锁持有时间

通过让锁资源尽快地释放,减少锁持有的时间,其他线程可更迅速地获取锁资源,进行其他业务的处理

锁分级

锁分级,指的是我们文章开始讲解的 Synchronied 锁的锁升级,属于 JVM 的内部优化,它从偏向锁开始,逐渐升级为轻量级锁、重量级锁,这个过程是不可逆的。

锁分离

我们在上面提到的读写锁,就是锁分离技术。这是因为,读操作一般是不会对资源产生影响的,可以并发执行;写操作和其他操作是互斥的,只能排队执行。所以读写锁适合读多写少的场景。

锁消除

通过 JIT 编译器,JVM 可以消除某些对象的加锁操作。举个例子,大家都知道StringBuffer 和 StringBuilder 都是做字符串拼接的,而且前者是线程安全的。

但其实,如果这两个字符串拼接对象用在函数内,JVM 通过逃逸分析这个对象的作用范围就是在本函数中,就会把锁的影响给消除掉。

对于读多写少的互联网场景,最有效的做法,是使用乐观锁,甚至无锁,

展开阅读全文
打赏
1
7 收藏
分享
加载中
更多评论
打赏
0 评论
7 收藏
1
分享
返回顶部
顶部