大数据Java技术问答

synchronized 解毒,剖析源码深度分析

2020-10-29  本文已影响0人  热衷技术的Java程序员

synchronized 解毒

image.png

1. 对象结构

1.1 对象结构介绍

image.png

「HotSpot虚拟机」 markOop.cpp 中的 C++ 代码注释片段,描述了 64bits 下 mark-word 的存储状态,也就是图 15-1 的结构示意。

这部分的源码注释如下:

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)

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

「另外」,在mark-word锁类型标记中,无锁,偏向锁,轻量锁,重量锁,以及GC标记,5种类中没法用2比特标记(2比特最终有4种组合00、01、10、11),所以无锁、偏向锁,前又占了一位偏向锁标记。最终:101为无锁、001为偏向锁。

1.2 验证对象结构

为了可以更加直观的看到对象结构,我们可以借助 openjdk 提供的 jol-core 进行打印分析。

「引入POM」

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-cli</artifactId>
    <version>0.14</version>
</dependency>

「测试代码」

public static void main(String[] args) {
    System.out.println(VM.current().details());
    Object obj = new Object();
    System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

1.2.1 指针压缩开启(默认)

「运行结果」

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

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

image.png

1.2.2 指针压缩关闭

在 Run-->Edit Configurations->VM Options 配置参数 -XX:-UseCompressedOops 关闭指针压缩。

「运行结果」

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 12 0c 53 (00000001 00010010 00001100 01010011) (1393299969)
      4     4        (object header)                           02 00 00 00 (00000010 00000000 00000000 00000000) (2)
      8     4        (object header)                           00 1c b9 1b (00000000 00011100 10111001 00011011) (465116160)
     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

image.png

1.2.3 对象头哈希值存储验证

接下来,我们调整下测试代码,看下哈希值在对象头中具体是怎么存放的。

「测试代码」

public static void main(String[] args) {
    System.out.println(VM.current().details());
    Object obj = new Object();
    System.out.println(obj + " 十六进制哈希:" + Integer.toHexString(obj.hashCode()));
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

「运行结果」

image.png

关于这个倒过来的问题是因为,大小端存储导致;

「mark-down结构」

image.png

如图 15-5 最右侧的 3 Bit(1 Bit标识偏向锁,2 Bit描述锁的类型)是跟锁类型和GC标记相关的,而 synchronized 的锁优化升级膨胀就是修改的这三位上的标识,来区分不同的锁类型。从而采取不同的策略来提升性能。

1.3 Monitor 对象

在HotSpot虚拟机中,monitor是由C++中ObjectMonitor实现。

synchronized 的运行机制,就是当 JVM 监测到对象在不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

那么三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。当一个 Monitor 被某个线程持有后,它便处于锁定状态。

「Monitor 主要数据结构如下」

// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;       // 记录个数
    _waiters      = 0,
    _recursions   = 0;       // 线程重入次数
    _object       = NULL;    // 存储 Monitor 对象
    _owner        = NULL;    // 持有当前线程的 owner
    _WaitSet      = NULL;    // 处于wait状态的线程,会被加入到 _WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

「锁执行效果如下」

image.png

如图 15-06,每个 Java 对象头中都包括 Monitor 对象(存储的指针的指向),synchronized 也就是通过这一种方式获取锁,也就解释了为什么 synchronized() 括号里放任何对象都能获得锁!

2. synchronized 特性

2.1 原子性

「原子性」是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。

「案例代码」

private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(() -> {
            for (int i1 = 0; i1 < 10000; i1++) {
                add();
            }
        });
        thread.start();
    }
    // 等10个线程运行完毕
    Thread.sleep(1000);
    System.out.println(counter);
}
public static void add() {
    counter++;
}

这段代码开启了 10 个线程来累加 counter,按照预期结果应该是 100000。但实际运行会发现,counter 值每次运行都小于 10000,这是因为 volatile 并不能保证原子性,所以最后的结果不会是10000。

修改方法 add(),添加 synchronized:

public static void add() {
    synchronized (AtomicityTest.class) {
        counter++;
    }
}

这回测试结果就是:100000 了!

因为 synchronized 可以保证统一时间只有一个线程能拿到锁,进入到代码块执行。

「反编译查看指令码」

javap -v -p AtomicityTest

public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=0
         0: ldc           #12                 // class org/itstack/interview/AtomicityTest
         2: dup
         3: astore_0
         4: monitorenter
         5: getstatic     #10                 // Field counter:I
         8: iconst_1
         9: iadd
        10: putstatic     #10                 // Field counter:I
        13: aload_0
        14: monitorexit
        15: goto          23
        18: astore_1
        19: aload_0
        20: monitorexit
        21: aload_1
        22: athrow
        23: return
      Exception table:

「同步方法」

ACC_SYNCHRONIZED 这是一个同步标识,对应的16进制值是 0x0020

这10个线程进入这个方法时,都会判断是否有此标识,然后开始竞争 Monitor 对象。

「同步代码」

2.2 可见性

在上一章节 volatile 篇中,我们知道它保证变量对所有线程的可见性。最终的效果就是在添加 volatile 的属性变量时,线程A修改值后,线程B使用此变量可以做出相应的反应,比如 while(!变量) 退出。

那么,synchronized 具备可见性吗,我们做给例子。

public static boolean sign = false;
public static void main(String[] args) {
    Thread Thread01 = new Thread(() -> {
        int i = 0;
        while (!sign) {
            i++;
            add(i);
        }
    });
    Thread Thread02 = new Thread(() -> {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException ignore) {
        }
        sign = true;
        logger.info("vt.sign = true  while (!sign)")
    });
    Thread01.start();
    Thread02.start();
}

public static int add(int i) {
    return i + 1;
}

这是两个线程操作一个变量的例子,因为线程间对变量 sign 的不可见性,线程 Thread01 中的 while (!sign) 会一直执行,不会随着线程 Thread02 修改 sign = true 而退出循环。

「现在」我们给方法 add 添加 synchronized 关键字修饰,如下:

public static synchronized int add(int i) {
    return i + 1;
}

「添加后运行结果」

23:55:33.849 [Thread-1] INFO  org.itstack.interview.VisibilityTest - vt.sign = true  while (!sign)

Process finished with exit code 0

可以看到当线程 Thread02 改变变量 sign = true 后,线程 Thread01 立即退出了循环。

注意:不要在方法中添加 System.out.println() ,因为这个方法中含有 synchronized 会影响测试结果!

「那么为什么添加 synchronized 也能保证变量的可见性呢?」

因为:

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中。
  2. 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  3. volatile 的可见性都是通过内存屏障(Memnory Barrier)来实现的。
  4. synchronized 靠操作系统内核互斥锁实现,相当于 JMM 中的 lock、unlock。退出代码块时刷新变量到主内存。

2.3 有序性

as-if-serial,保证不管编译器和处理器为了性能优化会如何进行指令重排序,都需要保证单线程下的运行结果的正确性。也就是常说的:「如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。」

这里有一段双重检验锁(Double-checked Locking)的经典案例:

public class Singleton {
    private Singleton() {
    }

    private volatile static Singleton instance;

    public Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

「为什么」,synchronized 也有可见性的特点,还需要 volatile 关键字?

因为,synchronized 的有序性,不是 volatile 的防止指令重排序。

那如果不加 volatile 关键字可能导致的结果,就是第一个线程在初始化初始化对象,设置 instance 指向内存地址时。第二个线程进入时,有指令重排。在判断 if (instance == null) 时就会有出错的可能,因为这会可能 instance 可能还没有初始化成功。

2.4 可重入性

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

那么我们就写一个例子,来证明这样的情况。

public class ReentryTest extends A{

    public static void main(String[] args) {
        ReentryTest reentry = new ReentryTest();
        reentry.doA();
    }

    public synchronized void doA() {
        System.out.println("子类方法:ReentryTest.doA() ThreadId:" + Thread.currentThread().getId());
        doB();
    }

    private synchronized void doB() {
        super.doA();
        System.out.println("子类方法:ReentryTest.doB() ThreadId:" + Thread.currentThread().getId());
    }

}

class A {
    public synchronized void doA() {
        System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
    }
}

「测试结果」

子类方法:ReentryTest.doA() ThreadId:1
父类方法:A.doA() ThreadId:1
子类方法:ReentryTest.doB() ThreadId:1

Process finished with exit code 0

这段单例代码是递归调用含有 synchronized 锁的方法,从运行正常的测试结果看,并没有发生死锁。所有可以证明 synchronized 是可重入锁。

synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。

「之所以」,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

3. 锁升级过程

关于 synchronized 锁升级有一张非常完整的图,可以参考:


image.png

synchronized 锁有四种交替升级的状态:无锁、偏向锁、轻量级锁和重量级,这几个状态随着竞争情况逐渐升级。

3.1 偏向锁

synchronizer源码:/src/share/vm/runtime/synchronizer.cpp

// NOTE: must use heavy weight monitor to handle jni monitor exit
void ObjectSynchronizer::jni_exit(oop obj, Thread* THREAD) {
  TEVENT (jni_exit) ;
  if (UseBiasedLocking) {
    Handle h_obj(THREAD, obj);
    BiasedLocking::revoke_and_rebias(h_obj, false, THREAD);
    obj = h_obj();
  }
  assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");

  ObjectMonitor* monitor = ObjectSynchronizer::inflate(THREAD, obj);
  // If this thread has locked the object, exit the monitor.  Note:  can't use
  // monitor->check(CHECK); must exit even if an exception is pending.
  if (monitor->check(THREAD)) {
     monitor->exit(true, THREAD);
  }
}

偏斜锁会延缓 JIT 预热进程,所以很多性能测试中会显式地关闭偏斜锁,偏斜锁并不适合所有应用场景,撤销操作(revoke)是比较重的行为,只有当存在较多不会真正竞争的 synchronized 块儿时,才能体现出明显改善。

3.2 轻量级锁

当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),JVM虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。

3.3 自旋锁

自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

自旋锁的默认大小是10次,可以调整:-XX:PreBlockSpin

如果自旋n次失败了,就会升级为重量级的锁。重量级的锁,在 1.3 Monitor 对象中已经介绍。

3.4 锁会降级吗?

之前一直了解到 Java 不会进行锁降级,但最近整理了大量的资料发现锁降级确实是会发生。

「When safepoints are used?」

Below are few reasons for HotSpot JVM to initiate a safepoint:
Garbage collection pauses
Code deoptimization
Flushing code cache
Class redefinition (e.g. hot swap or instrumentation)
Biased lock revocation
Various debug operation (e.g. deadlock check or stacktrace dump)

Biased lock revocation,当 JVM 进入安全点 SafePoint的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

上一篇下一篇

猜你喜欢

热点阅读