并发编程-04synchronized原理

发布于:2024-06-26 ⋅ 阅读:(46) ⋅ 点赞:(0)

并发编程-04synchronized原理

一 synchronized基础

1.1 并发安全问题

在学习synchronized原理之前,我们先要了解synchronized是干嘛用的,什么场景下需要使用它,以及它的使用方式有哪些?接下来我们去根据一个业务场景去了解下synchronized关键字的作用和使用。

思考:两个线程对初始值为0的静态变量一个做自增,一个做自减,各做 5000 次,结果是0吗?话不多说,我们直接上代码

@Slf4j
public class SyncDemo {

    private static int count =  0;

    private static void increment(){
        count++;
    }

    private static void decrement(){
        count--;
    }

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(()->{

            for(int i=0;i<5000;i++){
                increment();
            }

        },"thread1");



        Thread t2 = new Thread(()->{

            for(int i=0;i<5000;i++){
                decrement();
            }

        },"thread2");

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        log.info("线程1和线程2执行结束====》count:{}",count);

    }
}

运行结果:

22:49:44.207 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:-941

22:49:55.719 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:187

22:50:00.932 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:-545

结果可能是整数,也可能是负数,也有可能是零,结合业务场景我们肯定希望是并发情况下做了5000次自增操作、5000次自减操作后,结果为0,为什么还会有正数和负数的结果出现呢?这是因为在java中自增和自减操作并不是一个原子操作,我们可以查看一个i++和i–的JVM字节码指令

查看字节码文件,需要在idea中安装jclasslib插件

i++操作:

image-20230630225610055

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈
iadd // 自增 
putstatic //放到内存

i–操作:

image-20230630225700142

getstatic i // 获取静态变量i的值 
iconst_1 // 将int常量1压入操作数栈
isub // 自减 
putstatic//放到内存

如果仅仅是单线程,那么不涉及并发情况下,这些指令会按照顺序执行,不会有线程安全问题,但是如果多线程情况下这八个指令的执行可能是错乱的:

image-20230630230909666

接下来就要抛出一个概念了,那就是临界区。什么是临界区呢?

在一段代码内如果对共享资源有多线程读写操作,那么这段代码就是临界区,共享资源就是临界资源。比如我们的例子中的count变量就是临界资源,increment()方法和decrement()方法是临界区。而此时由于多个线程执行临界区的代码,对共享变量进行读写操作导致代码执行序列不同而产生不可预测的结果,这就发生了竞态条件。

为了解决竞态条件的发生,有多种手段可以实现:

  • 阻塞方案:syncrhonized、Lock
  • 非阻塞方案:原子变量,cas操作

注意:

虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码

同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

基于以上的Demo,我们往下了解下synchronized关键字的使用吧。

1.2 synchronized的使用

synchronized同步块是Java提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内置锁,也叫作监视器锁。

synchronized加锁方式有两种:

  • 修饰方法
  • 修饰代码块

image-20230630230927608

我们使用synchronized解决上边Demo的共享问题。

方式一:同步方法

    private static synchronized void increment(){
        count++;
    }

    private static synchronized void decrement(){
        count--;
    }

运行结果:

23:11:15.013 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

23:11:46.653 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

23:11:53.320 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

方式二:同步代码块

    private static  void increment(){
        synchronized (SyncDemo.class){
            count++;
        }

    }

    private static  void decrement(){
        synchronized (SyncDemo.class){
            count--;
        }
    }

运行结果:

23:12:50.815 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

23:13:13.423 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

23:13:19.843 [main] INFO juc.test.SyncDemo - 线程1和线程2执行结束====》count:0

加锁之后,那八个线程的执行顺序图如下:

image-20230630232959138

线程1只有等到线程2释放锁才能执行临界区的代码。

二 synchronized原理

synchronized是JVM内置锁,基于Monitor机制实现,依赖底层操作系统的互斥原语Mutex(互斥量),它是一个重量级锁,性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、自适应自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

Java虚拟机通过一个同步结构支持方法和方法中的指令序列实现同步:monitor。

同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

我们看一下加锁后的字节码文件

image-20230704195306246

image-20230704195332432

2.1 Monitor(管程/监视器)和MESA模型

Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。在Java 1.5之前,Java语言提供的唯一并发工具就是管程,Java 1.5之后提供的SDK并发包也是以管程为基础的。除了Java之外,C/C++、C#等高级语言也都是支持管程的。synchronized关键字和wait()、notify()、notifyAll()这三个方法是Java中实现管程技术的组成部分。在管程的发展史上,先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。下面我们便介绍MESA模型:

image-20230704121901155

管程中引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,条件变量等待队列的作用是解决线程之间的同步问题。Java中是通过wait()、notify()、notifyAll()三个方法实现线程的同步的。

所以一个MESA模型基本包括

  • 入口等待队列
  • 条件变量等待队列
  • 等待唤醒机制

wait():

对于MESA管程来说,有一个编程范式:

while(条件不满足) {
  wait();
}

唤醒的时间和获取到锁继续执行的时间是不一致的,被唤醒的线程再次执行时可能条件又不满足了,所以循环检验条件。MESA模型的wait()方法还有一个超时参数,为了避免线程进入等待队列永久阻塞。

notify()和notifyAll()

满足以下三个条件时,可以使用notify(),其余情况尽量使用notifyAll():

  • 所有等待线程拥有相同的等待条件。
  • 所有等待线程被唤醒后,执行相同的操作。
  • 只需要唤醒一个线程。

2.2 Java内置管程synchronzied

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。模型如下图所示。

image-20230704122344332

2.3 Monitor机制在Java中的实现

java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于 ObjectMonitor实现,这是 JVM 内部基于 C++ 实现的一套机制。

ObjectMonitor其主要数据结构如下(hotspot源码ObjectMonitor.hpp):

ObjectMonitor() {
    _header       = NULL; //对象头  markOop
    _count        = 0;  
    _waiters      = 0,   
    _recursions   = 0;   // 锁的重入次数 
    _object       = NULL;  //存储锁对象
    _owner        = NULL;  // 标识拥有该monitor的线程(当前获取锁的线程) 
    _WaitSet      = NULL;  // 等待线程(调用wait)组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;    
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁会先存到这个单向链表中 (FILO栈结构)
    FreeNext      = NULL ;
    _EntryList    = NULL ; //存放在进入或重新进入时被阻塞(blocked)的线程 (存竞争锁失败的线程)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

image-20230704122608519

在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略(QMode=0)是:如果EntryList为空,则将cxq中的元素按原有顺序插入到EntryList,并唤醒第一个线程,也就是当EntryList为空时,是后来的线程先获取锁。_EntryList不为空,直接从_EntryList中唤醒线程。所以synchronzied是非公平的锁

**当我们进入synchronzied代码块或者调用synchronzied修饰的方法的时候,锁对象是如何记录锁状态的呢?**这就需要我们去了解一下对象的内存结构了。

2.4 对象的内存布局

Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:比如 hash码,对象所属的年代,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

image-20230704123254070

对象头

HotSpot虚拟机的对象头包括:

  • Mark Word

    用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”。

  • Klass Pointer

    对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

  • 数组长度(只有数组对象有)

    如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节

    image-20230704123615181

接下来我们可以借助JOL工具查看对象内存布局

1.maven依赖

       <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>

2.测试代码

public class TestObject {

    public static void main(String[] args) {
        Object obj = new Object();
        //查看对象内部信息
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

3.运行结果:

1.开启指针压缩,总大小显示16字节,前12字节为对象头

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

2.关闭指针压缩,总大小显示16字节,前16字节为对象头

-XX:-UseCompressedOops

运行结果:

java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c eb 1c (00000000 00011100 11101011 00011100) (485170176)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

  • OFFSET:偏移地址,单位字节;
  • SIZE:占用的内存大小,单位为字节;
  • TYPE DESCRIPTION:类型描述,其中object header为对象头;
  • VALUE:对应内存中当前存储的值,二进制32位;

现在知道了对象的内存结构,我们再思考一下 synchronized加锁加在对象上,对象是如何记录锁状态的?其实它就是通过Mark Word来记录我们的锁状态的。

2.5 Mark Word记录锁状态

Hotspot通过markOop类型实现Mark Word,具体实现位于markOop.hpp文件中。由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间。简单点理解就是:MarkWord 结构搞得这么复杂,是因为需要节省内存,让同一个内存区域在不同阶段有不同的用处。

//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

。。。。。。
//    [JavaThread* | epoch | age | 1 | 01]       lock is biased toward given thread
//    [0           | epoch | age | 1 | 01]       lock is anonymously biased
//
//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object

32位的对象头如下图:

image-20230330090432101

64位的对象头如下图:

image-20230704125333148

  • hash: 保存对象的哈希码。运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。
  • age: 保存对象的分代年龄。表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • biased_lock: 偏向锁标识位。由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • lock: 锁状态标识位。区分锁状态,比如11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • JavaThreaId: 保存持有偏向锁的线程ID。偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。
  • epoch: 保存偏向时间戳。偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥,这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的Mark Word中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

Mark Word中锁标记枚举:

enum { locked_value             = 0,    //00 轻量级锁 
         unlocked_value           = 1,   //001 无锁
         monitor_value            = 2,   //10 监视器锁,也叫膨胀锁,也叫重量级锁
         marked_value             = 3,   //11 GC标记
         biased_lock_pattern      = 5    //101 偏向锁
     }

image-20230704195553428

2.6 使用JOL工具追踪锁标记变化

偏向锁

偏向锁是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了消除数据在无竞争情况下锁重入(CAS操作)的开销而引入偏向锁。对于没有锁竞争的场合,偏向锁有很好的优化效果。

偏向锁有一个状态叫做匿名偏向状态,即当JVM启用了偏向锁模式(jdk6默认开启),新创建对象的Mark Word中的Thread Id为0,也就是没有偏向任何线程,但是它的锁标记为101。

代码演示:

@Slf4j
public class LockEscalationDemo {
    
    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

    }
}

运行结果:

20:02:24.472 [main] DEBUG juc.test.LockEscalationDemo - java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

很疑惑,从结果来看锁状态还是001,这是为什么呢?这是因为偏向锁模式存在偏向锁延迟机制:HotSpot 虚拟机在启动后有个 4s 的延迟才会对每个新建的对象开启偏向锁模式。JVM启动时会进行一系列的复杂活动,比如装载配置,系统类初始化等等。在这个过程中会使用大量synchronized关键字对对象加锁,且这些锁大多数都不是偏向锁。为了减少初始化时间,JVM默认延时加载偏向锁。

代码演示:

@Slf4j
public class LockEscalationDemo {
    
    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

        Thread.sleep(4000);
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

    }
}

image-20230704200540950

从这次结果来看,4s后创建的对象是偏向锁,且线程id为0,也就是不偏向任何线程,此时处于匿名偏向状态。

如果我们要看LockEscalationDemo.class这个对象的对象头,它的锁状态标志会是什么?无论是4s前后,都是001,因为LockEscalationDemo.class会在我们jvm启动的时候创建好,所以那时候由于偏向锁的延迟效果,所以是无锁状态。

接下来我们就进行在执行synchronized同步代码快前后,偏向锁状态的变化吧

代码演示:

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

        Thread.sleep(4000);

        Object obj = new Object();

        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());

                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());

                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();

        Thread.sleep(1000);
        
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

运行结果:

image-20230704210247012

image-20230704210300131

进行同步代码块后,线程1获取锁,线程id开始变化,释放锁后,锁对象的线程id仍然指向线程1。

偏向锁撤销之调用hashcode:

接下来我们做一个操作,我们调用下对象的hashcode方法,看看对象的锁标志位会是什么变化

代码演示:

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

        Thread.sleep(4000);

        Object obj = new Object();
        obj.hashCode();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
    }  

运行结果:

image-20230704210833401

从结果来看,锁标志位是一个无锁状态,这是为什么呢?因为我们调用hashCode会导致该对象的偏向锁被撤销,因为对于一个对象,其HashCode只会生成一次并保存,偏向锁是没有地方保存hashcode的。那为什么轻量级锁或者重量级锁可以存放对象的hashcode值呢?这个答案我们在后边揭晓。这里可以先记住:

  • 轻量级锁会在锁记录中存放hashcode。
  • 重量级锁会在Monitior中记录hashcode。

当对象处于匿名偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向,两者的区别是什么呢?我们通过代码来演示:

1.当对象处于匿名偏向状态,调用Hashcode

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {

        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());

        Thread.sleep(4000);

        Object obj = new Object();
        log.debug(ClassLayout.parseInstance(obj).toPrintable());
        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                obj.hashCode();//匿名偏向状态下调用hashcode
                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());

                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();

        Thread.sleep(1000);

        log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

image-20230704214855537

从结果来看,此时线程1获取锁之后,锁状态变为了轻量级锁状态

2.当锁处于偏向状态后,调用hashcode方法

image-20230704215059622

从结果来看,线程1获取锁之后,锁状态从匿名可偏向状态变为偏向状态,调用hashcode之后,锁状态变为重量级锁。

小小总结一下:

当对象处于可偏向(也就是线程ID为0)和已偏向的状态下,调用HashCode计算将会使对象再也无法偏向:

  • 当对象可偏向时,MarkWord将变成未锁定状态,并只能升级成轻量锁;
  • 当对象正处于偏向锁时,调用HashCode将使偏向锁强制升级成重量锁。
偏向锁撤销之wait/notify

偏向锁状态执行obj.notify() 会升级为轻量级锁,调用obj.wait(timeout) 会升级为重量级锁

1.调用notify方法

代码演示:

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {



        Thread.sleep(4000);

        Object obj = new Object();

        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {

                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                log.debug(Thread.currentThread().getName()+"获取锁执行中。。。调用notify之前\n"
                        +ClassLayout.parseInstance(obj).toPrintable());

                  obj.notify();


                  log.debug(Thread.currentThread().getName()+"获取锁执行中。。调用notify之后。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                Thread.sleep(500);
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();

        Thread.sleep(2000);
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

运行结果:

image-20230704220527670

从结果来看:偏向锁在调用notify方法后变成了轻量级锁,最后释放锁成为无锁状态。

2.调用wait方法

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {



        Thread.sleep(4000);

        Object obj = new Object();

        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {

                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                log.debug(Thread.currentThread().getName()+"获取锁执行中。。。调用wait之前\n"
                        +ClassLayout.parseInstance(obj).toPrintable());


                    try {
                        obj.wait(100);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。调用wait之后。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                Thread.sleep(500);
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();

        Thread.sleep(2000);
        log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

运行结果:

image-20230704220910406

从结果来看,偏向锁在调用wait方法之后变为了重量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段,此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间多个线程访问同一把锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻微竞争时,偏向锁升级为轻量级锁:

代码演示:

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {



        Thread.sleep(4000);

        Object obj = new Object();

        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {

                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();

        Thread.sleep(1);
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread2");
        thread2.start();


        //log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

运行结果:

线程1拿到锁执行

image-20230704222147449

线程2拿到锁执行

image-20230704222237736

image-20230704222255805

从结果来看:锁状态标志经历一下阶段

1.最开始线程1执行,锁标志为匿名可偏向状态

2.线程1拿到锁之后,锁标志变为偏向状态且线程id为线程1的线程id

3.线程1释放锁,此时锁还是偏向锁且线程id还为线程1的id

4.线程2执行,锁还是偏向锁且线程id还为线程1的id

5.线程2执行到同步代码块,此时发现线程id不是自己的,这时只有自己一个线程竞争,升级为轻量级锁

6.线程2释放锁,锁重归为无锁状态。

当竞争激烈的适合,轻量级锁升级为重量级锁

代码演示:

@Slf4j
public class LockEscalationDemo {


    public static void main(String[] args) throws InterruptedException {



        Thread.sleep(4000);

        Object obj = new Object();

        new Thread(new Runnable() {
            @SneakyThrows
            @Override
            public void run() {

                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){

                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread1").start();


        Thread.sleep(1);



        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread2");
        thread2.start();

      


        Thread thread3 = new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug(Thread.currentThread().getName()+"开始执行。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
                synchronized (obj){
                    log.debug(Thread.currentThread().getName()+"获取锁执行中。。。\n"
                            +ClassLayout.parseInstance(obj).toPrintable());
                }
                log.debug(Thread.currentThread().getName()+"释放锁。。。\n"
                        +ClassLayout.parseInstance(obj).toPrintable());
            }
        },"thread3");
        thread3.start();

        //log.debug(ClassLayout.parseInstance(obj).toPrintable());

    }
}

线程1:拿到锁并释放锁

image-20230704224442545

线程2和线程3执行:此时锁状态是偏向锁且偏向线程1

image-20230704225211214

线程2拿到锁,升级为轻量级锁,此时markword是轻量级锁结构,并在栈帧中创建一个锁记录,和锁对象进行cas操作

image-20230704225257212

此时线程3来竞争锁,也会进行cas操作,但是此时会cas操作失败,故而升级为重量级锁

image-20230704225433403

其实在线程2释放锁之前锁已经升级为重量级锁了,我们看下线程2释放锁之前的锁对象结构,和线程3获取锁的时候的锁结构是一致的。

image-20230704225539941

最后线程3解锁:

image-20230704225812600

很疑惑这里为什么还是重量级锁,那是因为jvm恢复无锁状态需要时间,所以这里是还没有恢复到无锁状态的对象结构。

image-20230704225934584

三 synchronized锁优化

synchronized对锁的优化主要有偏向锁批量重偏向、批量撤销、自旋优化、锁粗化、锁消除等。接下来我们是以代码的形式来展示这些优化效果。

偏向锁批量重偏向

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级锁,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

批量重偏向原理:

以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,Jvm就认为该class的偏向锁有问题,因此会进行批量重偏向。

每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁对象,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。

当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值

intx BiasedLockingBulkRebiasThreshold = 20 //默认偏向锁批量重偏向阈值

我们可以通过-XX:BiasedLockingBulkRebiasThreshold 和 -XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值

代码演示:

@Slf4j
public class BiasedLockingTest {


    public static void main(String[] args) throws InterruptedException {

        //延时产生可偏向对象
        Thread.sleep(5000);
        // 创建一个list,来存放锁对象
        List<Object> list = new ArrayList<>();


        // 线程1
        new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                // 新建锁对象,且偏向当前线程
                Object lock = new Object();
                synchronized (lock) {
                    list.add(lock);
                }
            }
            try {
                //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead1").start();

        //睡眠3s钟保证线程thead1创建对象完成
        Thread.sleep(3000);
        log.debug("打印thead1,list中第20个对象的对象头:");
        log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));

        // 线程2
        new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                Object obj = list.get(i);
                synchronized (obj) {
                    if(i>=15){
                        log.debug("thread2-第" + (i + 1) + "次加锁执行中\t"+
                                ClassLayout.parseInstance(obj).toPrintable());
                    }
                }
                if(i==17||i==19){
                    log.debug("thread2-第" + (i + 1) + "次释放锁\t"+
                            ClassLayout.parseInstance(obj).toPrintable());
                }
            }
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead2").start();

        LockSupport.park();
    }

}

运行结果:

线程1执行完第20个锁对象解锁后偏向锁指向线程1

image-20230705211052437

线程2开始加锁,每次都是偏向锁撤销为轻量级锁,释放锁后为无锁状态

image-20230705211235046

线程2对第19个对象加锁,此时可以看出,偏向锁发生了重新偏向,偏向了线程2

image-20230705211359390

整个流程就是:

1.线程1对20个对象加偏向锁锁,这20个对象都偏向了线程1

2.线程2对1到18个个对象重新加锁,这时候会发生偏向锁撤销,变为了轻量级锁

3.到达偏向锁重偏向阈值,第19次加锁,此时偏向锁由偏向线程1改为偏向线程2

偏向锁批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会认为不该偏向,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

代码演示:

@Slf4j
public class BiasedLockingTest {


    public static void main(String[] args) throws InterruptedException {


            //延时产生可偏向对象
            Thread.sleep(5000);
            // 创建一个list,来存放锁对象
            List<Object> list = new ArrayList<>();

            // 线程1
            new Thread(() -> {
                for (int i = 0; i < 50; i++) {
                    // 新建锁对象
                    Object lock = new Object();
                    synchronized (lock) {
                        list.add(lock);
                    }
                }
                try {
                    //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
                    Thread.sleep(100000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, "thead1").start();

            //睡眠3s钟保证线程thead1创建对象完成
            Thread.sleep(3000);
            log.debug("打印thead1,list中第20个对象的对象头:");
            log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));

            // 线程2
            new Thread(() -> {
                for (int i = 0; i < 40; i++) {
                    Object obj = list.get(i);
                    synchronized (obj) {
                        if(i>=15&&i<=21||i>=38){
                            log.debug("thread2-第" + (i + 1) + "次加锁执行中\t"+
                                    ClassLayout.parseInstance(obj).toPrintable());
                        }
                    }
                    if(i==17||i==19){
                        log.debug("thread2-第" + (i + 1) + "次释放锁\t"+
                                ClassLayout.parseInstance(obj).toPrintable());
                    }
                }
                try {
                    Thread.sleep(100000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }, "thead2").start();


            Thread.sleep(3000);

            new Thread(() -> {
                for (int i = 0; i < 50; i++) {
                    Object lock =list.get(i);
                    if(i>=17&&i<=21||i>=35&&i<=41){
                        log.debug("thread3-第" + (i + 1) + "次准备加锁\t"+
                                ClassLayout.parseInstance(lock).toPrintable());
                    }
                    synchronized (lock){
                        if(i>=17&&i<=21||i>=35&&i<=41){
                            log.debug("thread3-第" + (i + 1) + "次加锁执行中\t"+
                                    ClassLayout.parseInstance(lock).toPrintable());
                        }
                    }
                }
            },"thread3").start();

            Thread.sleep(3000);
            log.debug("查看新创建的对象");
            log.debug((ClassLayout.parseInstance(new Object()).toPrintable()));

            LockSupport.park();

    }

}

运行结果:

1.线程1,1-50全部偏向线程1,释放锁后还是偏向锁状态

image-20230705212913001

2.线程2,1-18,发生偏向锁撤销,升级为轻量级锁,释放锁后是无锁状态

image-20230705213031559

3.线程2,19-40,发生偏向锁重偏向,指向线程2,且释放锁后还是偏向锁状态

image-20230705213224690

image-20230705213250507

4.线程3执行,0-18都是线程2的轻量级锁释放锁后的无锁状态,重新变为轻量级锁

image-20230705213450684

5.线程3执行,19-40都是指向线程2的偏向锁,再次发生偏向锁撤销,且达到阈值

image-20230705213620840

6.此时我们再创建一个锁对象,发现它是无锁状态(正常情况下应该的匿名偏向状态的偏向锁)

image-20230705213844187

这里对偏向锁批量重偏向和批量撤销做一个总结:

批量重偏向(bulk rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作而引起性能损耗。

批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为下次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)

锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到一个线程有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

	StringBuffer buffer = new StringBuffer();

    buffer.append("aaa").append(" bbb").append(" ccc");

上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到一个线程有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作,锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

这里stringBuffer是一个局部变量且不会逃逸出去故不存在线程安全问题,这里jit编译期间会把锁消除。

k rebias)机制是为了解决:一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作而引起性能损耗。

批量撤销(bulk revoke)机制是为了解决:在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为下次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)

锁粗化

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到一个线程有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

	StringBuffer buffer = new StringBuffer();

    buffer.append("aaa").append(" bbb").append(" ccc");

上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到一个线程有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作,锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

这里stringBuffer是一个局部变量且不会逃逸出去故不存在线程安全问题,这里jit编译期间会把锁消除。


网站公告

今日签到

点亮在社区的每一天
去签到