synchronized学习

1. synchronized的三种使用方式

  • 之前,学习集合类时,经常提到某些集合是线程同步的,或者说线程安全的
  • 因为,它们的方法使用synchronized进行修饰,以保证实例对象是线程安全的

1.1 普通同步方法

  • 普通同步方法:synchronized修饰普通成员方法,锁住的是当前类的实例对象

  • 普通同步方法如下:每隔10毫秒打印0 ~ 5的数值

    public synchronized void printNum() {
        try {
            for (int i = 0; i < 5; i++) {
                System.out.print("Thread-" + Thread.currentThread().getName() + ", " + i + "; ");
                Thread.sleep(10);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
  • 创建同一个实例对象,多线程访问该对象的printNum 方法

    • 多线程访问同一个实例对象的同步方法,需要等先进入的线程执行完同步方法退出后,另一个线程才能进入
    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();
        Thread thread1 = new Thread(() -> test.printNum());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test.printNum());
        thread2.setName("2");
        // 线程1执行完打印后,线程2才会执行
        thread1.start();
        thread2.start();
    }
    

    在这里插入图片描述

  • 如果不使用synchronized关键字,则多线程访问同一个对象的 printNum 方法,将出现顺序混乱
    在这里插入图片描述

  • 创建多个实例对象,不同线程访问不同的实例对象:

    • 每个线程获取的是不同的实例对象的锁,因此各自都能获取到锁,而无需等待
    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest();
        SynchronizedTest test2 = new SynchronizedTest();
    
        Thread thread1 = new Thread(() -> test1.printNum());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test2.printNum());
        thread2.setName("2");
        // 线程1和线程2交替打印
        thread1.start();
        thread2.start();
    }
    

    在这里插入图片描述

1.2 静态同步方法

  • 静态同步方法:synchronized修饰静态成员方法,锁住的是当前类的Class对象(整个类)

  • 静态同步方法如下:简单打印进入和退出同步方法的信息

    public synchronized static void printInfo() {
        try {
            System.out.println("Thread-" + Thread.currentThread().getName() + ": 进入同步方法");
            Thread.sleep(10);
            System.out.println("Thread-" + Thread.currentThread().getName() + ": 退出同步方法");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    
  • 多线程访问类的静态同步方法,即使通过不同的实例对象进行访问,最终因为作用于整个类,而保持同步

    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest();
        SynchronizedTest test2 = new SynchronizedTest();
    
        Thread thread1 = new Thread(() -> test1.printInfo());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test2.printInfo());
        thread2.setName("2");
        // 线程1执行完打印后,线程2才会执行
        thread1.start();
        thread2.start();
    }
    
  • 执行结果如下:

1.3 同步代码块

  • 同步代码块:synchronized修饰代码块,锁住的是synchronized关键字后指定的对象(实例对象或类的Class对象)

实例对象

  • synchronized关键字后,指定的对象是一个实例对象,则锁住的是该实例对象

    public void statementBlock() {
        synchronized (name) {
            try {
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 进入同步代码块, name: " + name);
                Thread.sleep(10);
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 退出同步代码块, name: " + name);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
  • 锁住的是成员变量name,是一个实例对象。

    • 线程1和2访问的都是对象test1 的statementBlock 方法,即想要获取的都是同一个name的锁
    • 因此,先进入的线程执行完同步语句块退出后,后续线程才能进入执行
    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest("lucy");
        
        Thread thread1 = new Thread(() -> test1.statementBlock());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test1.statementBlock());
        thread2.setName("2");
        // 线程1执行完打印后,线程2才会执行
        thread1.start();
        thread2.start();
    }
    
  • 执行结果如下:

  • 如果是不同的线程访问不同的SynchronizedTest对象,则可以互不影响

    • 线程1和线程2访问的是不同SynchronizedTest对象,获取的是不同的name的锁
    • 各自都能获取到锁,不会因为同步而阻塞
    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest("lucy");
        SynchronizedTest test2 = new SynchronizedTest("john");
    
        Thread thread1 = new Thread(() -> test1.statementBlock());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test2.statementBlock());
        thread2.setName("2");
        // 线程1和线程2互不影响
        thread1.start();
        thread2.start();
    }
    
  • 执行结果如下:

this

  • 特殊的,如果同步语句块指定的对象为this,则表示锁住当前类的实例对象,与普通同步方法等价

    public void statementBlock() {
        synchronized (this) {
            try {
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 进入同步代码块, " + this.toString());
                Thread.sleep(10);
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 退出同步代码块, " + this.toString());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
  • 多线程访问同一个SynchronizedTest对象的 statementBlock 方法

    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest("lucy");
      
        Thread thread1 = new Thread(() -> test1.statementBlock());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test1.statementBlock());
        thread2.setName("2");
    
        thread1.start();
        thread2.start();
    }
    
  • 执行结果如下:

  • 多线程访问不同ynchronizedTest对象的 statementBlock 方法

    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest("lucy");
        SynchronizedTest test2 = new SynchronizedTest("john");
    
        Thread thread1 = new Thread(() -> test1.statementBlock());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test2.statementBlock());
        thread2.setName("2");
    
        thread1.start();	
        thread2.start();
    }
    
  • 执行结果如下:

类的Class对象

  • synchronized关键字后,指定的对象是类的Class对象,则锁住的是该类

  • 下面的同步代码块,与同步静态方法等价,锁住都是SynchronizedTest类的Class对象

    public void statementBlock() {
        synchronized (SynchronizedTest.class) {
            try {
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 进入同步代码块, " + name);
                Thread.sleep(10);
                System.out.println("Thread-" + Thread.currentThread().getName() + ": 退出同步代码块, " + name);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
  • 即使通过不同的实例对象,访问 statementBlock 方法,最终获取的都是SynchronizedTest类的锁,存在线程同步

    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest("lucy");
        SynchronizedTest test2 = new SynchronizedTest("john");
    
        Thread thread1 = new Thread(() -> test1.statementBlock());
        thread1.setName("1");
        Thread thread2 = new Thread(() -> test2.statementBlock());
        thread2.setName("2");
    
        thread1.start();
        thread2.start();
    }
    
  • 执行结果如下:

1.4 总结

  • 普通同步方法,锁住的是当前类的实例对象
  • 静态同步方法,锁住的是当前类的Class对象
  • 同步语句块,锁住的是synchronized关键之后指定的对象;
    • 可以是一个实例对象,也可以是类的Class对象
    • 特殊的,如果指定的对象为this,与普通同步方法等价,锁住的是当前类的实例对象

2. synchronized的同步原理

  • 任何Java对象都有一个Monnitor对象与之关联,JVM基于进入和退出Monitor对象来实现方法同步或代码块同步
  • Monitor对象,简写为monitor,又称监视器锁
  • 同步方法和同步代码块,进入和退出monitor的实现细节有所差异

2.1 代码块的同步原理

  • 编写一个拥有同步代码块的方法

    public void statementBlock(){
        synchronized (this) {
            i++;
        }
    }
    
  • 使用javap -verbose -p SynchronizedTest命令,查看反编译后的结果

monitorEnter指令

  • 线程执行到monitorEnter指令,将尝试获取对象的monitor所有权,即尝试获取对象的锁
  • 同一时刻,有且只有一个线程能获取到monitor;从而保证同一时刻,只有一个线程能访问同步代码块,从而实现线程安全(同步)
  • 获取过程如下:
    • 如果monitor的进入计数器数为0,则该线程将计数器设置为1,成功进入monitor,成为monitor的所有者
    • 如果该线程已经是monitor的所有者,只是重新进入,则计数器加1(可重入)
    • 如果monitor已被其他线程持有,则该线程获取monitor失败,进入阻塞状态;
      • 直到monitor计数器为0,其他线程退出monitor,唤醒在同步队列中等待的线程
      • 这时,被唤醒的等待线程可以再次尝试进入monitor

monitorExit指令

  • 持有monitor的线程执行完同步代码块后,将执行monitorExit指令,使得计数器减1;
  • 若减1后计数为0时,线程退出monitor,唤醒同步队列中等待的线程;否则,该线程将继续持有monitor(可能存在线程多次进入monitor的情况)
  • 为什么会有两个monitorExit指令?
    • 第一个monitorExit指令,表示同步语句块正常结束后,退出monitor
    • 第二个monitorExit指令,表示同步语句块异常结束后,退出monitor

总结

  • JVM使用monitorEntermonitorExit指令,进入和退出monitor(获取和释放对象的锁),从而实现代码块同步

2.2 方法的同步原理

  • 普通同步方法定义如下:

    public synchronized void printInfo(){
        System.out.println("Hello");
    }
    
  • 使用javap -verbose -p SynchronizedTest命令,查看反编译后的结果

  • 这时,同步方法反编译后的结果中,不存在monitorEntermonitorExit指令

  • 但是,相对于普通方法,该方法的常量池中多了ACC_SYNCHRONIZED 标识符以实现方法同步

    • 调用方法时,调用指令将检查方法是否设置了ACC_SYNCHRONIZED 标识符
    • 如果设置了,执行线程需要先获取到monitor,然后才能执行方法体,方法执行完后释放monitor
    • 排他性: 方法执行期间,其他线程无法再获取同一个对象的monitor
  • ACC_SYNCHRONIZED 标识符来实现方法同步的

方法同步和代码块同步的一些说明

  • 两种同步方式本质上没有区别,只是方法同步是以一种隐式的方法来实现的,无需通过字节码来实现
  • 两种同步方式的底层,JVM都将调用操作系统的互斥原语mutex来实现
  • 获取monitor失败时,线程将被挂起、等待重新调度,会导致用户态 ⇒ \Rightarrow 内核态之间的来回切换,对性能有较大的影响
  • 说明: 获取锁和释放锁,都存在用户态 ⇒ \Rightarrow 内核态之间的来回切换,具体参考博客:1. Java并发编程的挑战

2.3 synchronized的可重入性

  • 在讲解代码块同步时,提到了重新进入monitor、计数器值减少为0等内容
  • 其实,就是synchronized的可重入性

为什么需要支持可重入?

  • 一个线程想获取已被其他线程持有的锁时,将处于阻塞状态

  • 但如果一个线程想访问自己已持有锁的临界资源,还是处于阻塞状态的话,将会天下大乱

    public class Widget {
        public synchronized void doSomething() {
            System.out.println("方法1执行...");
            doOthers();
        }
    
        public synchronized void doOthers() {
            System.out.println("方法2执行...");
        }
    }
    
  • 因为,获取的锁都还未释放就阻塞了,那么整个同步队列中阻塞的线程永远都无法收到锁释放、可以再次尝试获取锁的通知

    • 整体来看,不管是现在的、还是后续获取该锁的线程,都将阻塞
    • 以上面的代码为例,线程在调用doOthers()之前,需要将执行doSomething()时获取当前对象的锁释放掉。
    • 实际上该monitor已被该线程所持有且无法释放,此时会出现死锁的情况
  • 解决重复获取的问题,要么要求线程先释放锁、再获取锁,要么允许它重复获取

  • 如果先释放、再获取,可能存在以下问题:

    • 线程释放锁以后,下次还能在众多的竞争者中获取到锁吗?
    • 就算能再次获取到锁,释放锁后,将退出整个临界区,再获取锁的代码都不会执行了 😂
    • 同时,锁的释放和获取的开销非常大,还是不要乱搞的好
  • 因此,最好的方法还是允许可重复获取

synchronized的可重入

  • synchronized天生就具有可重入性,通过_recursions实现进入或退出monitor的计数
  • 如果想知道底层是如何实现可重入,可以阅读小米的技术博客:synchronized 实现原理,关注_recursions有关的代码
  • 如果为代码块同步增加可重入,则示意图如下

参考文档:

3. 对象头

  • 对象在内存中的布局可以分为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
  • 对象头: 存储了对象的类型、GC状态、同步状态、hashCode等基本信息
  • 对象头是变长的,只有当对象为数组类型时,才存储Array Length信息
  • 实例数据:
    • 说法一:存放类的属性数据信息,包括父类的属性数据信息(存储的是字段信息)
    • 说法二:对象中每个字段的值或数组中每个元素的值(更倾向于该说法)
  • 对齐填充: 为了字节对齐所填充的数据,非必须的

3.1 对象头整体结构

  • 对象头的三个组成部分,每个部分都是1个字宽

  • Java对象头一般占据2个字宽;若为数组类型,则还需要一个字宽记录数组长度

  • 字宽:由虚拟机的位数决定,在32bit的虚拟机中,则为4字节;在64bit的虚拟机中,则为8字节

为何数组类型需要多1个字宽记录数组长度?

  • JVM可以通过Java对象的元数据信息确定该对象的大小,但无法从数组的元数据来确认数组的大小
  • 因此,需要专门使用Array Length来记录数组长度

3.2 Mark Word的内容

  • 在JDK 1.6以前,synchronized基于monitor实现为对象加锁,又叫对象锁
  • 对象锁是笨重的:靠操作系统底层的mutex原语,加锁和解锁都需要在用户态和内核态之间来回切换,开销很大
  • 因此,对象锁又叫重量级锁或者互斥锁
  • 因此,JDK 1.6对synchronized进行了优化,引入了偏向锁轻量级锁锁升级,锁的存储结构也发生改变
  • synchronized的锁就存储在对象头中,准确来说,存储在对象头的Mark Word中

3.2.1 32bit虚拟机

  • Mark Word主要存储对象的hashCode、分代年龄(GC信息)、锁标志等信息

  • 按照synchronized锁的状态,有5种状态:无锁、偏向锁、轻量级锁、重量级锁、GC标记

  • 锁标志位(lock):表示锁的状态,00:轻量级锁,10:重量级锁,01:无锁或偏向锁,11:GC标记,表示对象等待GC回收

  • 是否偏向锁(biased_lock):无锁和偏向锁的锁标志位都是01,需要通过biased_lock区分无锁和偏向锁

  • 分代年龄(age):对象被GC的次数,当该次数达到阈值时,对象会转移到老年代

  • epoch:是一个时间戳,用于表示偏向锁的有效性

  • 指向锁记录的指针:ptr_to_lock_record,存在于轻量级锁中,指向栈中的锁记录。轻量级锁会在栈的锁记录中存储Mark Word,用于在释放锁时恢复Mark Word

  • 指向互斥锁(重量级锁)的指针:ptr_to_heavyweight_monitor,存在重量级锁中,指向对象的monitor。重量级锁,实质是依靠monitor实现

3.2.2 64bit虚拟机

  • 64bit虚拟机中,Mark Word的内容如下:

3.3 打印对象头信息

  • 有位同事分享,直接将对象头打印出来了,还说这里是Mark Word,这里是Class Pointer等

  • 当时觉得很高大上,等到自己深入学习对象头时,发现一个依赖就能搞定 😂

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.9</version>
    </dependency>
    
  • 例如,打印对象信息的代码如下

    public class SynchronizedTest {
        private int i;
    
        public SynchronizedTest() {
        }
    
        public SynchronizedTest(int i) {
            this.i = i;
        }
        
        public static void main(String[] args) {
            SynchronizedTest test = new SynchronizedTest(2);
            // 计算完hashCode前的对象头
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
            // 计算对象的hashCode
            System.out.println("hashCode(16进制): 0x"+Integer.toHexString(test.hashCode()));
    
            //当计算完hashcode之后,对象头的信息发生变化
            System.out.println("------------------ 计算hashCode后 ----------------------");
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
    
  • 执行结果如下,整个对象头为12字节:

    • 采用小端对齐:最低位的字节位于开始(高位),最高位的字节位于末尾(低位)
    • 一开始时,对象处于无锁状态001,且对象头中未存储hashCode
    • 计算hashCode后,锁标志的高4字节存储了hashCode
    • Class Pointer原本为8字节,由于JDK 1.8默认开启指针压缩,变成了4字节

为何无锁状态的Mark Word,最开始没有写入hashCode?

  • 在无锁状态下,hashCode采用延迟加载技术
  • 第一次调用hashCode()方法时,才会将计算得到的hashCode写入Mark Word

  • 通过设置-XX:-UseCompressedOops,关闭指针压缩,打印内容如下

参考文档:

3.4 对象头与monitor的关系

  • 针对基于操作系统的mutex原语实现对象锁十分笨重的问题,JDK 1.6对synchronized进行了改造,引入了偏向锁、轻量级锁、锁升级
  • synchronized的锁,存在Java的对象头中,准确地说,存在于对象头的Mark Word中
  • 当Mark Word的锁标志为10时,表示为重量级锁。这时,Mark Word中将存储指向monitor的指针
  • 可以说,重量级锁就是monitor锁

参考文档:

4. 通过打印对象头,了解锁的变化流程

4.1 一些基础知识

4.1.1 锁升级不是严格的

关于synchronized的锁状态,一般的描述如下

  • 一共有四种状态,级别从低到高依次为:无锁、偏向锁、轻量级锁、重量级

  • 这四种状态会随着竞争逐渐升级,并且只能升级不能降级(不包括无锁状态)

  • 这种只能升级不能降级的策略,是为了提高获取锁和释放锁的效率

  • 针对上面的描述,很多人都会产生一个误区:锁升级是严格的

    • 无锁只能升级为偏向锁,偏向锁只能升级轻量级锁,以此类推
  • 其实,锁的升级不是严格的

    • 在关闭偏向锁或偏向锁的启动延迟内,无锁可以直接 ⇒ \Rightarrow 轻量级锁;
    • 调用wait() 或hashCode() 方法,偏向锁可以直接 ⇒ \Rightarrow 重量级锁
  • 下面的图,概括了锁的变化流程

4.1.2 偏向锁的启动延迟

  • JDK 1.8中,默认开启偏向锁,对应jvm参数为-XX:+UseBiasedLocking
  • 执行如下的代码,猜测对象头的打印结果
    • 第一次打印时,处于无锁状态
    • 第二次打印时,处于偏向锁状态状态(无锁 ⇒ \Rightarrow 偏向锁)
    • 第三次打印时,恢复无锁状态
    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
        
        synchronized (test) {
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
        
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    }
    
  • 打印结果如下,与预期不符:无锁直接 ⇒ \Rightarrow 轻量级锁

为何默认开启了偏向锁,却在第一次获取锁时,无锁直接 ⇒ \Rightarrow 轻量级锁?

  • 这是因为偏向锁存在4秒的启动延迟,即在JVM启动4秒后,创建的对象才会开启偏向锁
  • 可以通过jvm参数-XX:BiasedLockingStartupDelay=0取消偏向锁的启动延迟

为何延迟启动偏向锁?

  • jvm的很多内部代码很多地方都使用了synchronized关键字,而且这些地方已经确定存在线程竞争
  • 在知道存在线程竞争的情况下,还从偏向锁升级到轻量级锁,会带来额外的性能损耗
  • 通过设置启动延迟,可以降低jvm启动时获取锁的性能损耗

  • 验证偏向锁的启动延迟:休眠5秒后,新建对象、获取对象的锁
    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
    
        SynchronizedTest test = new SynchronizedTest();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    
        synchronized (test) {
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    }
    
  • 执行结果如下:超过启动延迟后,创建的对象直接处于匿名偏向状态

4.1.3 匿名偏向

  • 上图中,第一次打印对象头时,发现对象的锁标志为101
  • 这就让人很疑惑了:都还没有线程获取锁,怎么一下就成偏向锁了?
  • 注意观察后面的2次打印,第一次打印的对象头中,没有线程ID
  • 这种特殊的偏向锁状态,叫匿名偏向
    • 当前对象的锁未被任何线程持有,处于无锁可偏向状态
    • 处于无锁可偏向状态的对象,访问同步代码块并获取锁时,将获取到偏向锁

关于无锁状态

  • 无锁状态,准确地说是无锁不可偏向状态
  • 处于无锁不可偏向状态的对象,获取该对象的锁时,不会转为偏向锁状态,而是直接升级为轻量级锁

匿名偏向状态的失效

  • 通过设置-XX:BiasedLockingStartupDelay=0,使对象创建后处于匿名偏向状态

  • 此时,若计算对象的hashCode,对象将回退到无锁状态

    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    
        System.out.println("hashCode(16进制): 0x"+Integer.toHexString(test.hashCode()));
    
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    
        synchronized (test) {
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
    
  • 执行结果如下:

    • 计算hashCode后,将从匿名偏向状态回退到无锁状态;
    • 获取到锁后,将从无锁状态更新为轻量级锁状态

4.1.4 总结:对象创建后的锁状态

  • 对象创建后,要么处于无锁状态,要么处于匿名偏向状态

无锁状态(无锁不可偏向状态):

  • 通过-XX:-UseBiasedLocking禁用偏向锁,对象创建后将处于无锁不可偏向状态
  • 在启动延迟内,创建的对象将处于无锁不可偏向状态

匿名偏向状态(无锁可偏向状态)

  • 开启偏向锁、取消启动延迟,创建的对象将处于匿名偏向状态
  • 超过启动延迟后,创建的对象将处于匿名偏向状态

特殊的

  • 处于匿名偏向状态的对象,在计算hashCode后,将转为无锁状态

4.2 偏向锁转为轻量级锁

  • 取消启动延迟-XX:BiasedLockingStartupDelay=0,使创建后的对象对于匿名偏向状态

    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
        synchronized (test) {
            System.out.println("-- main线程,获取同步锁 --\n" + ClassLayout.parseInstance(test).toPrintable());
        }
    
        Thread thread = new Thread(() -> {
            synchronized (test) {
                System.out.println("-- 子线程,获取同步锁 --\n" + ClassLayout.parseInstance(test).toPrintable());
            }
        });
    
        thread.start();
        thread.join(); // 等待子线程执行完
        System.out.println("-- 退出同步代码块 --\n" + ClassLayout.parseInstance(test).toPrintable());
    }
    
  • 执行结果如下:与上述流程图一样,匿名偏向 ⇒ \Rightarrow 偏向锁 ⇒ \Rightarrow 轻量级锁 ⇒ \Rightarrow 无锁

    • 通过main线程获取对象的偏向锁,退出同步代码块后,仍然保持偏向锁状态
    • 新建线程获取对象的锁,由于竞争而获取到轻量级锁
    • 退出同步代码块后,对象将处于无锁状态

4.3 偏向锁直接升级为重量级锁

  • 取消启动延迟-XX:BiasedLockingStartupDelay=0,使创建后的对象对于匿名偏向状态

  • 第一次获取锁时,为偏向锁;调用wait方法后,直接升级为重量级锁

    public static void main(String[] args) throws InterruptedException {
        SynchronizedTest test = new SynchronizedTest();
    
        Thread thread = new Thread(() -> {
            synchronized (test) {
                try {
                    System.out.println(ClassLayout.parseInstance(test).toPrintable());
                    test.wait(2000);
                    System.out.println(ClassLayout.parseInstance(test).toPrintable());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        thread.join();
    }
    
  • 执行结果如下

错误纠正


参考文档

5. 偏向锁

5.1 核心思想

前提

  • 大多数情况下,锁不仅不存在多线程竞争,而且总是被同一线程多次获得
  • 如果使用轻量级锁或重量级锁,反复加锁和解锁都存在一定的开销:轻量级锁,CAS操作;重量级锁,用户态和内核态之间的切换
  • 需要找到一种更合适的锁,降低同一线程反复加锁和解锁的开销

偏向锁的目标

  • 在只有一个线程执行同步代码块时,降低重复加锁和解锁的开销,提高性能

偏向锁的核心思想

  • 当一个线程访问同步代码块并获取锁时,通过CAS操作在Mark Word中存储当前线程的ID(匿名偏向 ⇒ \Rightarrow 偏向锁)
  • 存储线程ID,表示对象是偏向于该线程的;即使完成同步代码块的执行,也不会主动释放偏向锁
  • 以后该线程进入和退出同步代码块时,不需要花费CAS操作加锁和解锁,只需要检测Mark Word是否存储着指向该线程的偏向锁
  • 指向某个线程的偏向锁:① 锁状态为偏向锁(101);② Mark Word中存储该线程的ID

偏向锁 vs 轻量级锁

  • 引入偏向锁,是为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径
  • 轻量级锁的获取和释放,依赖多次CAS操作;偏向锁,只需要在第一次获取锁时,调用一次CAS操作设置线程ID即可

5.2 偏向锁的获取

  • 获取偏向锁时,只需要检查Mark Word中偏向锁、锁标志位和线程ID即可

处理流程如下:

  • 检查MarkWord是否为可偏向状态:偏向锁为1,锁标志为01
    • 可偏向状态,包括匿名偏向和偏向锁
    • 前者,表示没有线程持有锁,可以直接通过CAS操作竞争锁
    • 后者,表示已有线程持有偏向锁
  • 若为可偏向状态,则检测Mark Word中的线程ID是否为当前线程ID
    • 如果是,则执行同步代码块(重复获取偏向锁)
  • 如果不是当前线程ID,则通过CAS操作竞争锁
    • 竞争成功,通过CAS操作将Mark Word中的线程ID替换成当前线程ID
    • 并在栈帧的锁记录(Lock Record)中存储当前线程ID,然后开始执行同步代码块
  • CAS操作竞争锁失败,表明存在多线程竞争的情况。
    • 当到达全局安全点时,暂停持有偏向锁的线程,然后检查持有偏向锁的线程状态。
    • 根据持有偏向锁的线程状态,执行不同的操作:恢复无锁状态、偏向新的线程;偏向锁升级为轻量级锁

5.3 偏向锁的撤销

  • 偏向锁采用等到竞争出现才释放锁的机制,只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁
    • 这就能解释:线程获取偏向锁并退出同步代码块后,打印对象头,发现仍然处于偏向锁状态
    • 偏向锁的撤销需要等待全局安全点,这个时间点上没有正在执行的字节码
    • 全局安全点将导致短暂的STW(stop the world

偏向锁的撤销

  • 达到全局安全点后,暂停持有偏向锁的线程,然后检查该线程是否处于活动状态
  • 如果线程不处于活动状态,则将对象头设置成无锁状态(锁标识位01),然后重新偏向新的线程
  • 如果线程仍然活着,则遍历线程栈中的锁记录,检查对象的使用情况
    • 如果发现偏向的线程仍然需要持有偏向锁,则偏向锁升级为轻量级锁
      • 此时,轻量级锁仍然被原来持有偏向锁的线程持有,该线程恢复后继续执行同步代码块
      • 竞争线程会进入自旋,尝试获取轻量级锁
    • 如果偏向的线程不再需要持有偏向锁(退出了同步代码块),则将对象恢复成无锁状态,然后重新偏向新的线程

6. 轻量级锁

  • 轻量级,是相对于使用操作系统底层的mutex原语来实现的monitor(重量级锁)而言
  • 轻量级锁性能提升的依据:
    • 绝大部分的锁在整个生命周期内是不会存在竞争的
    • 也就是说,多线程访问同步块是交替的、并非同时的
    • 这样的话,通过CAS操作加锁、解锁,就比mutex原语涉及用户态、内核态切换的开销更小
  • 轻量级锁,适合多线程交替执行同步块的情况,可以避免重量级锁引起的性能消耗

6.1 轻量级锁的获取

  • 访问同步代码块时,如果对象为无锁状态(010),jvm首先在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间

    • Displaced Mark Word:锁记录中,存放对象当前Mark Word的拷贝
    • owner指针:锁记录中,指向当前对象Mark Word的指针
  • 拷贝对象头中的Mark Word到锁记录中,即拷贝对象头中的Mark Word到Displaced Mark Word中

  • 拷贝成功后,线程尝试通过CAS操作将Mark Word更新为指向栈中锁记录的指针(ptr_to_lock_record),并将锁记录中的owner指向对象的Mark Word

  • 更新成功,则线程获取到了对象的轻量级锁;此时,锁标识为00,表示对象处于轻量级锁定状态

  • 更新失败,需要判断对象的Mark Word是否指向当前线程的栈帧

    • 如果是,表明当前线程已经持有对象的轻量级锁,可以直接进入同步代码块继续执行(可重入)
    • 否则,表明存在锁竞争,尝试通过自旋获取锁;
      • 为了避免无用的自旋(持有锁的线程被阻塞)浪费CPU,自旋超过一定次数,轻量级锁膨胀为重量级锁
      • 另一情况: 一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁
      • 注意: 重量级锁状态,除了持有锁的线程外,其他线程都将阻塞,防止CPU空转

关于轻量级锁的重入

  • 重入时,栈中锁记录的Displaced Mark Word为null,只存储了指向对象Mark Word的指针
  • 重入的次数等于对象在栈帧中的锁记录数,这是隐式的锁重入计数器

为什么轻量级锁的竞争优先使用自旋方式?

  • 当对象的锁为轻量级锁时,其他线程竞争该对象的锁
  • 如果直接从轻量级锁升级为重量级锁,开销很大且可能是没必要的
  • 因为,持有轻量级锁的线程可能很快就执行完同步代码块了,竞争的线程只需要等待一段时间就能获取到锁了
  • 利用cpu的多核特性,占用cpu多次尝试获取锁的开销,会比直接阻塞线程、再唤醒的开销更小

  ~~~~ 欢迎大家和我交流 ~~~~

  • 看好多资料,在获取轻量级锁时,都说对象处于无锁状态
  • 最开始一度以为,这是基于锁状态变化图中,无锁 ⇒ \Rightarrow 轻量级锁这一条线
  • 疑问: 还有偏向锁升级为轻量级锁?还有自旋成功获取轻量级锁?
  • 根据之前的,synchronized锁的状态变化图,自己有以下的理解:
    • 有资料讲到,偏向锁升级为轻量级锁,需要先构建无锁状态的Mark word。
    • 如果获取轻量级锁之前,要求Mark Word必须为无锁状态的话,那就只能构建了,而非对象真的处于无锁状态
    • 这样就能解释:偏向锁升级为轻量级锁、自旋成功获取轻量级锁,Mark Word并非为无锁状态的情况了

其实吧,自己还有很多疑问,要想真的了解synchronized中锁的变化,读源码应该是最好的方式

6.2 轻量级锁的释放

轻量级锁的释放:

  • 通过CAS操作尝试将栈帧锁记录中的Displaced Mark Word替换对象的Mark Word
    • 即使用Displaced Mark Word恢复对象的Mark Word
    • CAS操作需要检查的是:Mark Word中的ptr_to_lock_record是否指向当前线程的锁记录
  • 替换成功,则表示没有竞争发生,同步过程结束
  • 替换失败,则表示存在锁竞争
    • 其他线程尝试获取轻量级锁失败,将轻量级锁修改成重量级锁,然后自身被挂起。
    • 此时,当前线程释放锁之后,还需要唤醒等待的线程

6.3 自旋锁和自适应自旋锁

6.3.1 自旋锁

  • 竞争锁的线程占有CPU,通过自旋获取锁,而非直接进入阻塞状态。这样的机制,叫做自旋锁

为何引入自旋锁?

  • 获取锁失败,线程阻塞;持有锁的线程释放锁,唤醒阻塞的线程;
  • 线程的阻塞与唤醒,存在用户态和内核态之间的切换,会对性能造成很大影响
  • 有时,线程持有锁的时间很短,让竞争的线程直接进入阻塞状态很不明智
  • 可以让竞争线程持有CPU,执行循环尝试获取锁

自旋锁的原理:循环的CAS操作

自旋锁的缺点:自旋锁虽然避免了线程的切换开销,但是会占用CPU时间。

自旋次数

  • 由于种种原因,竞争锁的线程可能无法在短时间内自旋获取到锁,会导致CPU的浪费
  • 因此,需要限制自旋的次数,避免无用的自旋
  • 自旋次数默认为10,可以使用-XX:PreBlockSpin来进行设置

其他说明:

  • 自旋锁在JDK1.4.2中引入,默认是关闭的
  • 在JDK1.6中变成默认开启,并引入了自适应自旋锁,以解决自旋锁中浪费CPU资源的问题

6.3.2 自适应自旋锁

  • 自适应意味着自旋的次数不再固定,而是根据上一次在同一个锁自旋的次数和锁的拥有者的状态来决定。
  • 如果在同一个锁对象上自旋刚刚成功获取过锁,并持有锁的线程处于运行状态,则可以认为这一次自旋也很可能成功,允许它自旋更长的时间。
  • 如果在一个锁上,自旋很少成功,则下一次可以省略自旋过程,直接阻塞线程,避免浪费CPU资源。

7. 总结

  • 说实话,自己最开始想学习synchronized时,也从未想过需要学习这么多内容
  • 同时,学习的过程也让我很崩溃,尤其是自己没有阅读源码的,加锁和解锁过程都是看别人咋说的,然后还把自己看得迷迷糊糊的
  • 直到现在,自己也是迷迷糊糊的 😂

7.1 重点总结

  • synchronized的三种使用方式及其加锁范围:普通同步方法、静态同步方法、同步语句块
  • Java对象的内存布局:
    • 三大块:对象头、实力数据、对齐填充(可选的);
    • 对象头的内容:Mark Word(存储对象自身运行时数据)、Class Pointer、Array Length(数组类型)
    • 对象头的大小:2字宽或3字宽,字宽的大小取决于jvm的位数
    • synchronized的锁存储在对象头的Mark Word中,Mark Word如何表示对象的5种状态:无锁、偏向锁、轻量级锁、重量级锁、GC标记
    • 通过jol-core依赖打印对象头(64bit的jvm默认开启指针压缩,可以通过-XX:-UseCompressedOops关闭指针压缩)
  • monitor
    • JDK 1.6以前,synchronized通过进入和退出monitor对象实现代码同步或方法同步
    • 代码块同步、方法同步的实现原理,可重入性(_recursions
    • mointor就是重量级锁,底层依靠操作系统的mutex原语实现,十分笨中
  • 对象锁状态的转换流程图
    • 无锁,实际为无锁不可偏向状态;匿名偏向,实际为无锁可偏向状态
    • 匿名偏向状态的失效 ⇒ \Rightarrow 无锁,偏向锁的失效 ⇒ \Rightarrow 重量级锁
    • 默认开启偏向锁,但存在启动延迟;jvm相关参数:-XX:BiasedLockingStartupDelay=0-XX:+UseBiasedLocking;不同情况下,新建对象后,对象的状态
  • 偏向锁:
    • 只有一个线程执行同步代码块时,降低重复加锁和解锁的开销
    • 一次CAS操作获取偏向锁,同一线程后续再进入或退出同步代码块,只需要检测Mark Word是否存储着指向该线程的偏向锁
    • 偏向锁的获取:检查可偏向、锁标志位、线程ID
    • 偏向锁的撤销:有竞争才撤销、全局安全点、检查持有偏向锁的线程状态
  • 轻量级锁
    • 轻量的来源:相对与使用操作系统底层的mutex原语实现的重量级锁而言
    • 轻量级锁的获取:
      • Mark Word的拷贝、CAS更新ptr_to_lock_record
      • 更新失败:可重入或存在锁竞争(自旋获取轻量级锁,自旋失败膨胀为重量级锁)
    • 轻量级锁的释放:
      • CAS操作恢复Mark Word
      • CAS操作失败(已经膨胀为重量级锁),释放锁的同时唤醒阻塞的线程
    • 自旋锁(JDK1.4.2引入,占用CPU,循环尝试)与自适应自旋锁(解决自旋锁浪费CPU的问题,JDK 1.6引入)

7.2 synchronized几种锁的对比

  • 学习完以后,自己对几种锁的整体映像
    • 偏向锁,针对只有一个线程访问同步代码块的场景打造
    • 轻量级锁,针对多线程交替访问同步代码块的场景打造
    • 重量级锁,针对多线程同时访问同步代码块的场景打造
  • 下面的总结来自《Java并发编程的艺术》一书
优点缺点使用场景
偏向锁加锁和解锁不需要额外的消耗(加锁的CAS操作不算消耗?准确地说,应该是重复加锁和解锁?),和执行非同步方法相比仅存在纳秒级出具如果线程间存在锁竞争,会带来额外的锁撤销的消耗(全局安全点的STW?)只有一个线程访问同步块
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间、同步块执行速度非常快
重量级锁线程不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量、同步块执行速度较长(执行时间较长?)

7.3 后记

  • 很多讲解synchronized的文章,还提及了锁消除、锁粗化等
  • 由于学习范文是《Java并发编程的艺术》一书,书中尚未提及的知识,这里暂不展开
  • 说实话,能将书中提及的知识学透一点,已经够自己喝一壶了

参考链接:

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值