Java 并发 —— Synchronized 关键字和锁升级 【可下载 Synchronized_思维导图】

目录

一、Synchronized使用场景

1.1 为什么需要上锁?

1.2 Synchronized 哪些方式同步

二、Synchronized实现原理

2.1 markword 内存布局

2.2 Synchronized 在 jvm 实现原理

三、Synchronized 锁升级

3.1 锁升级为什么这样设计?

3.1.1 偏向锁

3.1.2 轻量级锁(自旋锁)

四、Synchronized 思维导图(可下载)


一、Synchronized使用场景

Synchronized 是一个 Java 同步关键字,在某些多线程场景下,如果不进行同步会导致数据不安全,而 Synchronized 关键字就是用于代码同步。什么情况下会数据不安全呢,要满足两个条件:一是数据共享(临界资源)二是多线程同时访问并改变该数据。

下面是模拟火车票售卖的示例程序,具体如下:

// Java 并发 —— Synchronized 关键字和锁升级 【争取小白也能看懂】
// 线程操作资源类 - 共享资源
class TrainTicketSale {
    // 火车票剩余张数
    int remainTicketNum = 1_000;

    // 卖火车票,卖一张少一张,返回true 表示售票成功,返回false 表示售票失败
    public synchronized boolean sale() {
        if (remainTicketNum > 0 ) {
            // 模拟网络延时
            try {
                TimeUnit.MILLISECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(System.currentTimeMillis() + "\t" + Thread.currentThread().getName() + "\t剩:" + --remainTicketNum);
            return true;
        }

        return false;
    }

    // 获取还剩下多少张火车票
    public int getRemainTicketNum() {
        return remainTicketNum;
    }
}
public class T05_Juc_SynchronizedAtomic {

    public static void main(String[] args) throws Exception {
        TrainTicketSale sale = new TrainTicketSale();

        // 运行结果:火车票还剩下:-1 张
        System.out.println("火车票还剩下:" + sale.getRemainTicketNum() + " 张");

        Vector<Thread> vector = new Vector<>(10);
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(() -> {
                while (true) {
                    if (!sale.sale()) break;
                }
            }, "线程名:" + i);
            vector.add(thread);
            thread.start();
        }

        // 等待售票线程卖票完毕
        for (Thread thread : vector) {
            thread.join();
        }

        // 运行结果:如果没有加 synchronized 关键字,票会出现超卖现象;添加了synchronized 关键字测不会出现超卖
        System.out.println(" Finally 火车票还剩下:" + sale.getRemainTicketNum() + " 张");
    }
}

测试一:没有使用 Synchronized 关键字同步售卖 sale() 方法,火车票出现了超卖现象。 

 测试二:使用 Synchronized 关键字同步售卖 sale() 方法,火车票没有出现超卖问题了,解决共享资源在多线程下数据同步问题


1.1 为什么需要上锁?

多个线程去访问同一个资源的时候,访问某一段代码或者某临界资源的时候是需要有一把锁的概念的。

比如:我们对一个数字做递增,两个程序对它一块儿来做递增,递增就是把一个程序往上加1,如果两个线程共同访问的时候,第一个线程读它是0,然后把它加1,在自己线程内部内存里面算还没有写回去的时候,第二个线程读到了它还是0,加1再写回去,本来加了两次,但是还是1。

那么我们在对这个数字递增的过程中上把锁,就是说第一个线程对这个数字访问的时候是独占的,不允许别的线程来访问,不允许别的线程来对它进行计算,我必须加完1再释放锁,其他线程才能对它继续加。实质上,这把锁并不是对数字进行锁定的,你可以任意指定,想锁谁就锁谁。


1.2 Synchronized 哪些方式同步

我第一个程序是这么写的,如果说你想上了把锁之后才能对count进行减减访问,你可以new一个Object,所以这里锁定就是lock,当我拿到这把锁的时候才能执行这段代码。

1. Synchronized 修饰同步代码块:锁对象是 Synchronized 后面括号里配置的对象,这个对象可以是某个对象(xLock)

2.Synchronized 修饰同步代码块:锁对象是 Synchronized 后面括号里配置的对象,这个对象可以是某个对象(xLock)

3.Synchronized 修饰普通同步方法:锁对象当前实例对象,实际上是对调用该方法的对象加锁,俗称“对象锁”

4.Synchronized 修饰静态同步方法:锁对象是当前的类 Class 对象,实际上是对该类 Class 对象加锁,俗称“类锁”。

总结一下:Synchronized 锁的3种使用形式(使用场景):

  1. Synchronized 修饰普通同步方法:锁对象当前实例对象;
  2. Synchronized 修饰静态同步方法:锁对象是当前的类 Class 对象;
  3. Synchronized 修饰同步代码块:锁对象是 Synchronized 后面括号里配置的对象,这个对象可以是某个对象(xLock),也可以是某个类(TLock.class);

二、Synchronized实现原理

2.1 markword 内存布局

Java对象由三部分构成:对象头、实例数据、对齐补充。

  • 对象头:存储对象自身运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志等;
  • class pointer:类型指针,存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐;
  • 对齐补充:8字节对齐补充;

通过第一部分可以知道,Synchronized 不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么 Synchronized锁对象是存在哪里的呢?答案是存在锁对象的对象头的 MarkWord中。那么 MarkWord在对象头中到底长什么样,也就是它到底存储了什么呢?

Synchronized 优化的过程和 markword 息息相关,用 markword 中最低的三位代表锁状态  其中1位是偏向锁位  两位是普通锁位

上图中的偏向锁和轻量级锁都是在 jdk 6 以后对锁机制进行优化时引进的,下文的锁升级部分会具体讲解,Synchronized 关键字对应的是重量级锁,接下来对重量级锁在 Hotspot JVM中的实现锁讲解。


2.2 Synchronized 在 jvm 实现原理

在之前的博客文章中也有介绍 Synchronized 相关知识:Java 内存模型 —— 用示例剖析 ,下面我们做一个 Synchronized 同步代码块的代码及对应的字节码,如下:

可以看出同步方法块在进入代码块时插入了 monitorenter 语句,在退出代码块时插入了 monitorexit 语句,为了保证不论是正常执行完毕(第6行)还是异常跳出代码块(第12行)都能执行monitorexit语句,因此会出现两句monitorexit语句。

同时,我们得出这样一个结论:Synchronized自动上锁,自动释放锁

出于兴趣,继续深入 monitorenter 细节,发现 monitorenter 是由C++ 实现的,在 InterpreterRuntime.cpp文件中第608行moniterenter 方法,moniterenter 方法说明,如果使用了偏向锁则执行 fast_enter;如果没有使用偏向锁则执行slow_enter。


三、Synchronized 锁升级

在 JDK早期,Synchronized 叫做重量级锁,jvm 向操作系统申请一把锁,都需要从用户态向系统态的一个转换 Synchronized(lock)  在jdk1.2之前,由OS帮忙管理线程,整个实现过程复杂涉及用户空间向系统空间申请锁,属于重量级锁。

在 JDK后期(jdk 1.6),Synchronized 有锁升级过程,无锁 -> 偏向锁 -> 自旋锁 -> 重量级锁(操作系统OS管理)。这样一来,避免一上来就跟系统空间申请锁资源,比如:单线程场景 或 多线程下竞争不激烈时,先由 jvm 来实现共享资源数据的同步,属于轻量级锁。

话不多说,直接 Synchronized 锁升级流程图,如下:

如上图所示,图中有偏向锁。默认情况 偏向锁启动有个时延,默认是4秒

why? 因为 JVM 虚拟机自己有一些默认启动的线程,里面有多好 sync 代码,这些 sync 代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断地进行锁撤销和锁升级的操作,效率较低,可以通过参数:-XX:BiasedLockingStartDelay=0 调整偏向锁启动时间,不过一般我们不会调节它的。

Synchronized 锁升级流程:

  1. 如果有线程上锁,上偏向锁,指的就是,把 markword 的线程ID改为自己线程ID的过程,偏向锁不可重偏向;
  2. 如果有线程竞争,撤销偏向锁,升级轻量级锁,线程在自己的线程栈生成 LockRecord,用 CAS 操作将 markword 设置为指向自己这个线程的 LR 的指针,设置成功者得到锁。
  3. 如果竞争加剧,竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半,1.6之后,加入自适应自旋 adapative Self Spinning,jvm 自己控制

总结:

  • new - 偏向锁 - 轻量级锁(也叫无锁,自旋锁,自适应自旋)- 重量级锁
  • Synchronized 优化的过程和 markword 息息相关
  • 用 markword 中最低的三位代表锁状态  其中1位是偏向锁位  两位是普通锁位

3.1 锁升级为什么这样设计?

锁的4种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)

3.1.1 偏向锁

为什么要引入偏向锁?

  • 因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的升级

当线程1 访问代码块并获取锁对象时,会在 java对象头和栈帧中记录偏向的锁的 threadID,因为偏向锁不会主动释放锁,因此以后线程1 再次获取锁的时候,需要比较当前线程的 threadID 和 Java 对象头中的 threadID 是否一致,如果一致(还是线程1获取锁对象),则无需使用 CAS 来加锁、解锁;如果不一致(其他线程,如线程2 要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1 的 threadID),那么需要查看 Java 对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

偏向锁撤销

为什么频繁的偏向锁撤销会导致STW时间增加呢?阅读偏向锁源码可以知道:偏向锁的撤销需要等待全局安全点(safe point),暂停持有偏向锁的线程,检查持有偏向锁的线程状态。首先遍历当前JVM的所有线程,如果能找到偏向线程,则说明偏向的线程还存活,此时检查线程是否在执行同步代码块中的代码,如果是,则升级为轻量级锁,进行CAS竞争锁。可以看出撤销偏向锁的时候会导致stop the word。

偏向锁的优化(取消或延迟):

因为偏向锁的撤销是非常重的操作,甚至会导致STW,那么我们可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁。偏向锁默认是系统启动几秒钟后启用偏向锁,默认为4秒,原因在于,系统刚启动时,一般数据竞争是比较激烈的,此时启用偏向锁会降低性能,可以使用参数进行设置:-XX:BiasedLockingStartupDelay=5


3.1.2 轻量级锁(自旋锁)

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋等待锁释放。

偏向锁是否一定比自旋锁效率高?

不一定,在明确知道会有多线程竞争的情况下,偏向锁肯定会涉及锁撤销,这时候直接使用自旋锁;JVM启动过程,会有很多线程竞争(明确),所以默认情况启动时不打开偏向锁,过一段儿时间再打开。

为什么有自旋锁,还需要重要级锁?

自旋是消耗CPU资源的,如果锁的时间长,或者自旋线程多,CPU会被大量消耗;重量级锁有等待队列,所有拿不到锁的进入等待队列,不需要消耗CPU资源,重量级锁会通过操作系统,将线程放入等待队列_WaitSet

轻量级锁什么时候升级为重量级锁?

线程1 获取轻量级锁时会先把锁对象的对象头 MarkWord 复制一份到线程1 的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用 CAS 把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址

如果在线程1 复制对象头的同时(在线程1 CAS 之前),线程2 也准备获取锁,复制了对象头到线程2 的锁记录空间中,但是在线程2 CAS 的时候,发现线程1 已经把对象头换了,线程2 的 CAS 失败,那么线程2 就尝试使用自旋锁来等待线程1 释放锁

但是如果自旋的时间太长也不行,因为自旋是要消耗 CPU 的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1 还没有释放锁,或者线程1 还在执行,线程2 还在自旋等待,这时又有一个线程3 过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

 

*注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)

四、Synchronized 思维导图(可下载)

Synchronized 思维导图下载链接:Synchronized_思维导图(全面).xmind.zip

大纲如下:


文章最后,给大家推荐一些受欢迎的技术博客链接

  1. JAVA相关的深度技术博客链接
  2. Flink 相关技术博客链接
  3. Spark 核心技术链接
  4. 设计模式 —— 深度技术博客链接
  5. 机器学习 —— 深度技术博客链接
  6. Hadoop相关技术博客链接
  7. 超全干货--Flink思维导图,花了3周左右编写、校对
  8. 深入JAVA 的JVM核心原理解决线上各种故障【附案例】
  9. 请谈谈你对volatile的理解?--最近小李子与面试官的一场“硬核较量”
  10. 聊聊RPC通信,经常被问到的一道面试题。源码+笔记,包懂
  11. 深入聊聊Java 垃圾回收机制【附原理图及调优方法】

欢迎扫描下方的二维码或 搜索 公众号“大数据高级架构师”,我们会有更多、且及时的资料推送给您,欢迎多多交流!

                                           

       

猜你喜欢

转载自blog.csdn.net/weixin_32265569/article/details/108168792