Java“锁”事

Updated on with 0 views and 0 comments

前言

Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。

我们先来说一下我们为什么需要锁?

JMM规范操作变量都需要在线程各自的工作内存中进行,操作完成后需要写入到主内存中,这个过程没法保证原子性,在多线程环境中容易出现问题。
我们要做的也就是保证多线程环境下 共享的、可修改的变量状态的正确性(这里的状态指的是程序里的数据),在java程序中我们可以使用synchronized关键字来对程序进行加锁。

当声明synchronized代码块的时候,编译成的字节码将包含monitorenter指令 和monitorexit指令。
注意:jdk 1.6以前synchronized 关键字只表示重量级锁,1.6之后区分为偏向锁、轻量级锁、重量级锁。

javap 反汇编

代码如下:

public class A {
    private static Object obj = new Object();
    
    public static void main(String[] args) {
        synchronized (obj) {
            System.out.println("hello synchronized");
        }
    }
}

我们要看synchronized的原理,但是synchronized是一个关键字,看不到源码。我们可以将class文件
进行反汇编。
JDK自带的一个工具: javap ,对字节码进行反汇编,查看字节码指令。
:javap -v A结果如下图:
image.png

类似下面这种结构
image.png

monitorenter

每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获
取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应
的monitor的所有权。其过程如下:

1.如果monitor的计数为0,线程可以进入monitor,并将monitor的计数设置为1,当前线程成为monitor的owner(拥有者)。
2.若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的计数加1。
3.若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直
到monitor的进入数变为0,才能重新尝试获取monitor的所有权。

monitorenter小结:
synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待。

monitorexit

  1. 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
  2. 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个 monitor的所有权。

同步方法在反汇编后,会增加 ACC_SYNCHRONIZED 修饰。会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

monitor监视器锁

可以看出无论是synchronized代码块还是synchronized方法,其线程安全的语义实现最终依赖一个叫monitor的东西,那么这个神秘的东西是什么呢?
注:jvm源码下载-> http://openjdk.java.net/ --> Mercurial --> jdk8 --> hotspot --> zip

在HotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp)。ObjectMonitor主要数据结构如下:

ObjectMonitor() {
 _header     = 0;
 _count      = 0;
 _waiters    = 0;
 _recursions = 0; // 线程的重入次数
 _object     = NULL;//存储该monitor的对象
 _owner      = NULL;//表示拥有该monitor的线程
 _waitSet    = NULL;//处于wait状态的线程,会被加入到该_waitSet
 _cxq        = NULL;多线程竞争时的单向链表
 _EntryList = NULL;//处于等待锁block状态的线程,会被加入该列表
//下略...

每一个Java对象都可以与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。

我们的Java代码里不会显示地去创造这么一个monitor对象,我们也无需创建,事实上可以这么理解:monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对象创建关联的monitor对象

我们看下执行monitorenter执行时的HotSpot源码位于:src/share/vm/interpreter/interpreterRuntime.cpp的InterpreterRuntime::monitorenter函数

//%note monitor_1

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))

#ifdef ASSERT

  thread->last_frame().interpreter_frame_verify_monitor(elem);

#endif

  if (PrintBiasedLockingStatistics) {

    Atomic::inc(BiasedLocking::slow_path_entry_count_addr());

  }

  Handle h_obj(thread, elem->obj());

  assert(Universe::heap()->is_in_reserved_or_null(h_obj()),

         "must be NULL or an object");

  //如果启动了偏向锁走偏向锁逻辑

  if (UseBiasedLocking) {

    // Retry fast entry if bias is revoked to avoid unnecessary inflation

    ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);

  } else {

    //否则直接重量锁逻辑

    ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);

  }

  assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),

         "must be NULL or an object");

#ifdef ASSERT

  thread->last_frame().interpreter_frame_verify_monitor(elem);

#endif

IRT_END

综上我们可以得到一个结论:monitor是重量级锁
ObjectMonitor的函数调用中会涉及到Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized是Java语言中是一个重量级(Heavyweight)的操作。

对象布局/JDK6 synchronized优化

总所周知Java对象由三个基本结构组成
image.png

CAS全称: Compare And Swap(比较相同再交换)。是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令,被广泛运用于并发实现中,这里不多叙述

下面我们就通过JOL来分析java的对象布局:

<!-- 首先添加JOL的依赖 -->
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
//test class
class DemoTest{
}
public class LockAnalysis {

    public static void main(String[] args) throws InterruptedException {
        DemoTest demoTest = new DemoTest();
        //打印对象布局
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }
}

结果如下:
image.png

从运行结果可以分析出一个空的对象为16Byte,其中对象头 (object header) 占12Byte,剩下的为对齐字节占4Byte(也叫对齐填充,jvm规定对象头部分必须是 8 字节的倍数); 由于这个对象没有任何字段,所以之前说的对象实例是没有的(0 Byte);

引申出两个问题?
1.什么叫做对象的实例数据

2.对象头 (object header)里面的12Byte到底是什么?

首先要明白对象的实例数据很简单,我们可以在DemoTest当中添加一个boolean的字段,boolean字段占1byte,然后运行看结果。

//test class
class DemoTest{
    boolean a;
}
public class LockAnalysis {

    public static void main(String[] args) throws InterruptedException {
        DemoTest demoTest = new DemoTest();
        //打印对象布局
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }
}

image.png

分析结果:
整个对象的大小没有改变还是一共16Byte,其中对象头 (object header) 占12Byte,boolean 字段 DemoTest.a(对象的实例数据)占1Byte,剩下的3Byte为对齐填充;

由此我们可以认为一个对象的布局大体分为三个部分分别是:对象头(object header)、对象的实例数据、对齐填充;

接下来讨论第二个问题对象头 (object header)里面的12Byte到底是什么?为什么是12Byte?里面分别存储的什么?(64bits的VM);

首先引用openjdk文档中对对象头的解释:

object header:
Common structure at the beginning of every GC-managed heap object. (Every oop points to an object header.) Includes fundamental information about the heap object's layout, type, GC state, synchronization state, and identity hash code. Consists of two words. In arrays it is immediately followed by a length field. Note that both Java objects and VM-internal objects have a common object header format.

上述引用中提到了一个java对象头包含了2个word,并且包含了堆对象的布局、类型、GC状态、同步状态和标识哈希码,但是具体是怎么包含的呢?又是哪两个word呢?请继续看openjdk的文档:

mark word:
The first word of every object header. Usually a set of bitfields including synchronization state and identity hash code. May also be a pointer (with characteristic low bit encoding) to synchronization related information. During GC, may contain GC state bits.
注:mark word为第一个word根据文档可以知道他里面包含了锁的信息,hashcode,gc信息等等。

klass pointer
The second word of every object header. Points to another object (a metaobject) which describes the layout and behavior of the original object. For Java objects, the "klass" contains a C++ style "vtable".
注:kclass word为第二个word根据文档可以知道这个主要指向对象的元数据。

object header
mark word klass word

假设我们理解一个对象主要由上图两部分组成(数组对象除外,数组对象的对象头还包含一个数组长度),

那么一个对象头(object header)是多大呢?

我们从hotspot(jvm)的源码注释中得知一个mark word是一个64bits(源码:Mark Word(64bits) ),那么klass的长度是多少呢?

所以我们需要想办法来获得java对象头的详细信息,验证一下他的大小,验证一下里面包含的信息是否正确。

根据上述JOL打印的对象头信息可以知道一个对象头(object header)是12Byte(96bits),而JVM源码中:Mark Word为8Byte(64bits),可以得出 klass是4Byte(32bits)(jvm默认开启了指针压缩:压缩:4Byte(32bits);不压缩:8byte(64bits))
和锁相关的就是mark word了,接下来重点分析mark word里面信息。

java对象头当中的mark word里面的第1个字节( 00000001 )中存储的分别是:
image.png

对象状态一共分为五种状态,分别是无锁、偏向锁、轻量锁、重量锁、GC标记,
那么2bit,如何能表示五种状态(2bit最多只能表示4中状态分别是:00,01,10,11)

jvm是把偏向锁和无锁状态表示为同一个状态,然后根据图中偏向锁的标识再去标识是无锁还是偏向锁状态;

(题外话:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为16。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。)

什么意思呢?写个代码分析一下,在写代码之前我们先记得无锁状态下的信息为00000001,其中偏向锁标识为: 0, 此时对象的状态为 01; 然后写一个偏向锁的例子看看结果:

偏向锁

偏向锁:

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
  • 在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
  • 当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
  • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
  • 偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
public class LockAnalysis {

    static DemoTest demoTest;

    public static void main(String[] args) {
        demoTest = new DemoTest();
        System.out.println("befor lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());

        //加锁
        sysn();

        System.out.println("after lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }


    public static void sysn() {
        synchronized (demoTest) {
            System.out.println("lock ing");
            System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        }
    }
}

执行结果如下:
image.png
上述代码只有一个线程去调用sysn()方法;故而讲道理应该是偏向锁,但是发现输出的效果(第一个字节)依然是:

befor lock
00000001

lock ing
01001000  //居然是0 00 不是1 01,为啥会出现这种情况呢?

after lock
00000001

经过翻hotspot源码发现:

虚拟机在启动的时候对于偏向锁有延迟,延迟是4000ms。

验证一下再运行代码之前先给主线睡眠5000ms再来看下结果:

public class LockAnalysis {

    static DemoTest demoTest;

    public static void main(String[] args) throws InterruptedException {
        //睡眠5000ms
        Thread.sleep(5000);
        demoTest = new DemoTest();
        System.out.println("befor lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        //加锁
        sysn();
        System.out.println("after lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }


    public static void sysn() {
        synchronized (demoTest) {
            System.out.println("lock ing");
            System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        }
    }
}

image.png

befor lock
00000101

lock ing
00000101

after lock
00000101

之前的 0 变成了1 说明偏向锁的biased_lock状态已经启用了,偏向锁标识为: 1 此时对象的状态为 01 ;需要注意的是after lock,退出同步后依然保持了偏向信息;

想想为什么偏向锁会延迟?
因为jvm 在启动的时候需要加载资源,这些对象加上偏向锁没有任何意义啊,减少了大量偏向锁撤销的成本;所以默认就把偏向锁延迟了4000ms;

为了方便我们测试我们可以直接通过修改jvm的参数来禁止偏向锁延迟(不用在代码睡眠了):
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

注意:这块严谨来说,在jdk 1.6之后,关于使用偏向锁和轻量级锁,jvm是有优化的,在没有禁止偏向锁延迟的情况下,使用的是轻量级锁;禁止偏向锁延迟的话,使用的是偏向锁;
👆上面我们证明了锁的状态为偏向锁:1 01。

偏向锁好处

偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。
它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。
在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。但在应用程序启动几秒钟之后才
激活,可以使用 -XX:BiasedLockingStartupDelay=0 参数关闭延迟,如果确定应用程序中所有锁通常
情况下处于竞争状态,可以通过 XX:-UseBiasedLocking=false 参数关闭偏向锁。

原理简答:当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操
作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每
次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

下面我们来分析轻量级锁(注意在不禁止延迟偏向锁的情况下验证):

//test class
class DemoTest {
    boolean a;
}

public class LockAnalysis {

    static DemoTest demoTest;
    public static void main(String[] args) throws InterruptedException {
        demoTest = new DemoTest();
        System.out.println("befor lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());

        //加锁
        sysn();

        System.out.println("after lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }

    public static void sysn(){
        /**
         * 因为偏向锁延迟,这里使用的是轻量级锁
         */
        synchronized (demoTest){
            System.out.println("lock ing");
            System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        }
    }
}

image.png
可以看出轻量级锁对象的状态为 00。会自动释放

接下来我们来分析重量级锁(注意在不禁止延迟偏向锁的情况下验证):

轻量级锁:

  • 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
  • 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
  • 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
  • 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
  • 如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
  • 若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
  • 多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒
  • 在没有锁竞争的前提下,减少传统锁使用OS互斥量产生的性能损耗
  • 在竞争激烈时,轻量级锁会多做很多额外操作,导致性能下降
  • 可以认为两个线程交替执行的情况下请求同一把锁

轻量级锁的释放

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据。

  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。

  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级
    锁。

轻量级锁好处

在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

//test class
class DemoTest {
    boolean a;
}

public class LockAnalysis {

    static DemoTest demoTest;
    public static void main(String[] args) throws InterruptedException {
        demoTest = new DemoTest();
        System.out.println("befor lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());

        Thread t1 = new Thread() {
            @Override
            public void run() {
                synchronized (demoTest) {
                    try {
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };

        t1.start();
        System.out.println("t1 lock ing");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());

        sysn();

        System.out.println("after lock");
        System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
    }


    public static void sysn(){
        synchronized (demoTest){
            System.out.println("main lock ing");
            System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        }
    }
}

结果如下:
image.png

befor lock
00000001  //无锁

t1 lock ing
10110000  //轻量级锁

main lock ing
01001010  //重量级锁

after lock
01001010 //重量级锁

看出重量级锁对象的状态为 10

但是会发现在after lock之后还是重量级锁,是因为重量级锁释放会有延迟,可以在sync()方法中加入睡眠:

 public static void sysn() throws InterruptedException {
        synchronized (demoTest){
            System.out.println("main lock ing");
            System.out.println(ClassLayout.parseInstance(demoTest).toPrintable());
        }
        Thread.sleep(5000);
    }

可以看到after之后的状态为0 01 无锁的状态:

image.png

汇总

此时我们到这里就已经通过分析java对象头找出锁的对象的状态:

image.png

锁消除

锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享
数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,
堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们
是线程私有的,同步加锁自然就无须进行。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确
定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步呢?实际上有
许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍程度也许超过了大部分读者的
想象。下面这段非常简单的代码仅仅是输出3个字符串相加的结果,无论是源码字面上还是程序语义上
都没有同步。

public class A {
    public static void main(String[] args) {
        contactString("aa", "bb");
    }
    public static String contactString(String s1, String s2) {
        StringBuffer sBuf = new StringBuffer();
        // append方法是同步操作
        sBuf.append(s1);
        sBuf.append(s2);
        return sBuf.toString();
    }
}

StringBuffer的append ( ) 是一个同步方法,代码中concatString()方法中的局部对象sBuf,就只在该方法内的作用域有效,不同线程同时调用concatString()方法时,都会创建不同的sBuf对象,因此此时的append操作若是使用同步操作,就是白白浪费的系统资源。因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作
用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线
程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对
象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操
作也会导致不必要的性能损耗。

public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append("aa");
        }
        System.out.println(sb.toString());
    }

什么是锁粗化?JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放
到这串操作的外面,这样只需要加一次锁即可。

平时写代码如何对synchronized优化

  1. 减少synchronized的范围
  2. 降低synchronized锁的粒度(ConcurrentHashMap1.7分段锁)
  3. 读写分离(ConcurrentHashMapCopyOnWriteArrayListConyOnWriteSet)

标题:Java“锁”事
作者:liqitian3344
地址:https://liqitian.com/articles/2020/07/15/1594779472114.html