java并发编程笔记_02_锁_synchronized

第二章:锁_synchronized


在上一章我们已经大概说过对象头中的markword,现在让我们再来加深一下对它的理解

01、Monitor

每个java对象都可以关联一个Monitor对象,当使用synchronized给该对象上锁的时候,这个对象的 markword就会设置指向为这个monitor对象。这个monitor对象是操作系统层面的。

初始的时候,monitor对象的 拥有者是空,当一个对象被synchronized,thread1线程最先赶来访问该方法,并且成功的将monitor的拥有者变成了他自己。如果在thread1上锁的过程中,thread2,3,4也来访问,那么他们三个就会被放到blockList中,进行阻塞。当thread1执行完同步代码块中的内容时,就会唤醒blockList中阻塞状态的线程,之后他们三个竞争,竞争是非公平的。

waitSet中的两个线程是之前获取到过锁,但是由于条件不满足,从而进入了waiting状态。

通过以上可以说明,synchronized锁住的对象必须是同一个对象,否则就不会被monitor监听。

02、synchronized原理

看一段代码:

package cn.cmysz.blog;

public class SynchronizedPrinciple {
    static Object lock = new Object();
    static int current = 0;
    public static void main(String[] args) {
        synchronized (lock) {
            current++;
        }
    }
}

对应的字节码为:

 0 getstatic #2 <cn/cmysz/blog/SynchronizedPrinciple.lock> //表示获取锁
 3 dup													   //表示将锁的引用复制了一份
 4 astore_1												   //将刚才的引用保存了下来
 5 monitorenter											   //进入临界区
 6 getstatic #3 <cn/cmysz/blog/SynchronizedPrinciple.current> 
 9 iconst_1
10 iadd
11 putstatic #3 <cn/cmysz/blog/SynchronizedPrinciple.current>
14 aload_1												   //6到14表示的是进行++操作
15 monitorexit											   //退出临界区
16 goto 24 (+8)											   //走return返回
19 astore_2												   //将异常存储
20 aload_1												   //将上方刚才保存好的锁引用读取
21 monitorexit											   //重新进行退出锁
22 aload_2												   //读取异常
23 athrow												   //抛出异常
24 return

以上可以看到synchronized在字节码层面的作用范围,当执行到临界区以外的时候,释放锁

临界区就是synchronized(lock){} 大括号包括着的那部分区域。

那么大家可以看到,19到23之间又发生了一次退出锁的操作,这是为什么?

Exception Table:

Nr.Start PCEnd PCHandler PCCatch Type
061619cp_info #0
1192219cp_info #0

在异常表中我们可以看见,如果临界区中的代码出现了异常之后,就会走到19到23行之间的字节码指令,重新进行一次释放锁的操作。

注意:方法级别的synchronized指令不会在字节码中存在,例如:

public synchronized void m1(){
    current++;
}
0 getstatic #3 <cn/cmysz/blog/SynchronizedPrinciple.current>
3 iconst_1
4 iadd
5 putstatic #3 <cn/cmysz/blog/SynchronizedPrinciple.current>
8 return

03、轻量级锁

如果一个锁对象要被多个线程加锁,但是加锁的时间是错开的(也就是说不会发生争抢)的时候,那么可以使用轻量级锁来进行优化。

轻量级锁的加锁过程:

public void m1() {
    synchronized (lock) {
        current++;
    }
}

public void m2() {
    synchronized (lock) {
        current++;
    }
}

假设现在有两个线程A,B分别要调用m1,m2方法,但是他们的调用时间不同,一个是白天调用,一个是晚上调用,那么他们之间不发生对锁的竞争关系。

在线程A的内部创建锁记录对象,锁记录对象中有两部分,一个是锁记录对象的引用吗,一个是锁对象的引用,让锁对象的引用指向锁对象,然后进行CAS替换锁对象中对象头的markword。

如果交换成功,那么锁对象中的markword与锁记录对象中的锁记录引用发生替换。,表示由线程A给对象加锁。

如果CAS失败那么失败的情况有两种。

1、发生了synchronized的锁重入

public void m1() {
    synchronized (lock) {
        m2();
    }
}

public void m2() {
    synchronized (lock) {
        current++;
    }
}

当发生锁重入的时候,在当前线程中再次创建锁记录对象,但是锁记录对象的指针为null,锁记录对象中的锁对象指针还是指向锁对象。这时锁重入计数就会累加。

2、此锁对象之前已经有线程为它加了锁,那么就会进行锁膨胀,将轻量级锁膨胀到重量级锁。

当临界区的代码执行完毕,退出的时候,会出现三种情况

1、当重入次数为0时,表示没有重入,那么直接退出即可。退出就是将上一次进行的替换操作再一次的替换回来。

2、将锁对象头中的锁记录对象指针与锁记录中保存的锁对象的markword进行替换。

当有重入次数时,退出synchronized代码块,如果有取值为null的情况,那么进行锁记录的重置,相当于锁重入计数减减操作。

3、当步骤1失败时,说明此时轻量级锁已经升级了重量级锁,那么就进入重量级锁的解锁流程。将monitor的拥有者设置为null,并且唤醒blockList中的进行阻塞的线程。

04、锁膨胀

线程1使用CAS为对象加锁,CAS失败,发现线程1已经对该对象加过锁了,那么就会进行锁膨胀,为当前锁对象申请monitor锁(该锁是操作系统层面的)。让锁对象的markword指向monitor,当前加锁失败的线程进入monitor的blockList中进行阻塞。将轻量级锁升级为重量级锁。

在解锁的时候,线程2想通过CAS将引用恢复之前的状态,但是由于thread1已经将锁升级到了重量级锁,那么CAS就会失败。走的是重量级锁的解锁流程。重量级锁的解锁流程在上面已经说过。

05、自旋优化

在重量级锁进行竞争的时候,以前是一个线程获取到了锁,另一个线程就会去monitor中的阻塞队列进行阻塞。

我们可以使用自旋来进行优化。如果将要被阻塞的线程自旋成功,那么他就不用进入阻塞队列。

解释:p1与p2进行争抢,p1争抢到cpu的执行权,p2没有争抢到,就会进行自旋操作。不会进入monitor的blockList中进行阻塞,这样就减少了cpu在线程之间的调度引发的线程上下文之间的切换,但是在p2进行自旋的过程中它也是会不停的进行尝试获取锁(tryLock),如果自旋达到一定程度时,还是没有获取到锁,那么它就会进入monitor的阻塞队列中进行阻塞。

总结:

1、自旋操作会占用CPU的时间,如果是单核的CPU进行自旋操作,那么就是浪费性能。多核CPU进行自旋操作才会体现出性能的提升。

2、在jdk6之后的自旋是自适应的,比如对象刚刚进行自旋操作成功过,这次自旋的成功的可能性就会高,就会多自旋几次,相反,会较少的自旋甚至不自旋。

06、偏向锁

回忆对象头中的markdown

1、偏向锁的状态

偏向锁状态默认是开启的并且是延迟的,此时markword最后三位是101,这时它的thread,epoch,age都是0,

如果没有开启偏向锁(可以使用vm参数-XX:-UseBiasedLocking来禁用偏向锁),那么它的hashCode,age都是0。

hashCode一开始都是没有的,当第一次调用的时候,才会生成并返回,调用该方法可以撤销偏向锁。

当thread1来给对象上锁时使用CAS来进行替换,替换成功,将自己的线程id(此时的线程id是操作系统的线程id不是java中)放在锁对象的markword中。此时加锁成功。

2、偏向锁的撤销

​ 1、调用hashCode方法。现在偏向锁对象中存放的是threadID,如果调用该方法就会导致threadID替换为hashCode并返回。

​ 轻量级锁会在锁记录中存放hashCode

​ 重量级锁会在monitor中存放hashCode

​ 2、当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。

​ 3、调用wait/notify:wait/notify只有重量级锁才有,当调用这两个方法的时候,会将偏向锁,轻量级锁都升级到重量级锁。

3、批量重偏向

如果当前锁对象被多个线程访问,但是没有产生竞争关系,那么,偏向t1线程的锁对象也是有偏向t2线程的可能的。

在t2线程获取锁之前,锁对象的thread还是t1线程的id,在t2线程获取锁对象的时候,将之前的偏向锁升级为轻量级锁,当t2线程走出临界区之后,将锁对象的markword设置成不可偏向状态(normal状态)。重新偏向的时候会重置锁对象的线程id。如果这样反复的偏向,撤销偏向次数达到20次(从第二十个开始),那么就会进行批量的重偏向。将第20个到第40个之间的锁对象的threadId都会设置成为t2线程的id

4、批量撤销

超过阈值40(从第40个开始)的时候,jvm就会将这个类包括所有的对象设置为不可偏向的状态。

07、锁消除

@Test
public void m2(){
    current++;
}

@Test
public  void m1(){
    Object lock2 = new Object();
    synchronized (lock2) {
        current++;
    }
}

当测试方法m2()与方法m1()时,打印他们的执行时间,会发现,他们的执行时间几乎是相同的,这是由于jvm使用逃逸分析对代码进行的优化。

补充:

使用逃逸分析,编译器可以对代码做如下优化:

一、**栈上分配**。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

二、**同步省略**。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

三、**分离对象或标量替换**。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

synchronized关键字在字节码阶段还会存在(monitorenter<—>monitorexit是它的作用范围),在运行的时候才会考虑把它去掉。

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步代码块所使用的的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

显然m1()方法就是利用了同步省略,所以这两个方法的执行时间才会几乎一致。

本文参照视频:

https://www.bilibili.com/video/BV1jE411j7uX?p=88

https://www.bilibili.com/video/BV1PJ411n7xZ?p=2

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Java中的Lock是一种更高级别的线程同步机制,它比传统的synchronized关键字更加灵活,性能也更好。Java中的Lock要求显式地获取和释放,而synchronized则会自动获取和释放。下面介绍一下Lock的使用及其常见的使用场景。 ### Lock的使用 Java中的Lock接口定义了一组方法,用于获取、释放以及其他一些与相关的操作。Lock的常用实现类有ReentrantLock、ReentrantReadWriteLock.ReadLock和ReentrantReadWriteLock.WriteLock等。 下面是一个简单的使用ReentrantLock的示例: ```java import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class LockDemo { private Lock lock = new ReentrantLock(); public void method() { lock.lock(); // 获取 try { // 这里是需要同步的代码块 } finally { lock.unlock(); // 释放 } } } ``` 在上面的示例中,我们使用了ReentrantLock来实现的功能。在需要同步的代码块前调用lock()方法获取,在同步代码块执行完后调用unlock()方法释放。 ### Lock的使用场景 Lock的使用场景与synchronized类似,都是在多线程环境下对共享资源进行同步。但是,由于Lock的灵活性更强,所以它的使用场景比synchronized更加广泛。 下面是一些常见的Lock的使用场景: - 高并发情况下的线程同步:在高并发情况下,使用Lock可以提供更好的性能,因为它的实现比synchronized更加高效。 - 读写分离的情况下的线程同步:在读写分离的情况下,使用ReentrantReadWriteLock可以实现读写,使得读操作可以并发执行,而写操作需要独占,保证数据的一致性。 - 死避免:在使用synchronized时,如果由于某些原因没有及时释放,就可能导致死。而使用Lock时,可以在获取的时候设置超时时间,避免死的发生。 总之,Lock是Java中一种强大的线程同步机制,使用时需要注意的获取和释放,以及异常处理等问题,但它的灵活性和性能优势使得它成为Java并发编程中不可或缺的一部分。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值