1. 由一个问题引发的思考

       线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核CPU以及超线程技术来实现线程的并发执行;第二个是线程的异步化执行相比于同步化执行来说,异步执行能够很好的优化程序的处理能力提升并发的吞吐量。但是,这样也会带来很多的麻烦,来看如下代码:

  public class Demo {
    private static int count = 0;
    public static void inc() {
        try {
            Thread.sleep(1);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i  Demo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果: " + count);
    }
}

多个线程对count同一个变量进行修改就会存在一个数据安全性的问题。
       一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及线程中是如何使用这个对象的。所以如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。
那么如何保证线程并行的安全性?
       问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使得线程的并行变成串行,那么就不会存在这个问题了吧?如果要达到这个目的,可以通过加锁的方法,而且这个锁需要实现互斥的特性。Java中提供加锁的方法就有synchronized关键字。

2. 初识synchronized关键字

利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。

2.1 synchronized关键字的基本语法

   1.对于普通同步方法,锁的是当前实例对象
   2.对于静态同步方法,锁的是当前类的Class对象
   3.对于同步方法块,锁的是Synchonized括号里配置的对象。

不同的修饰类型,代表锁的控制粒度。
synchronized关键字“给某个对象加锁”,示例代码:

public Class MyClass {
    public void synchronized method1() {
        // ...
    }
    public static void synchronized method2(){
        // ...
    }
}

等价于:

public class MyClass { 
    public void method1() { 
        synchronized(this) { 
            // ... 
        } 
    }
    public static void method2() { 
        synchronized(MyClass.class) {
            // ...
        } 
    } 
}

实例方法的锁加在对象myClass上;静态方法的锁加在MyClass.class上。

2.2 从字节码层面看synchronized

2.2.1 同步代码块

先看以下同步代码块的一段代码:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("HelloWorld");
        }
    }
}

查看其字节码:

字节码

关于这两条指令的作用,我们直接参考JVM规范中描述:
monitorenter :

  每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
  1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

  执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,
  如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

2.2.2 同步方法

先看下代码:

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反编译得到:

编译结果

       从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

2.3 锁是如何存储的

观察synchronized 的整个语法发现,synchronized(lock) 是基于lock这个对象的生命周期来控制锁的粒度的,那么是不是锁的存储和这个lock对象有关呢?

2.3.1 对象在内存中的布局

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

对象的两种内存布局

对象的两种内存布局

探究JVM源码实现
当我们在Java代码中,使用new创建一个对象实例的时候,(hotspot虚拟机)JVM层面会创建一个instanceOopDesc 对象。

Oop模型

       Hotspot 虚拟机采用 OOP-Klass 模型来描述 Java 对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass 用来描述对象实例的具体类型。Hotspot 采用instanceOopDesc 和 arrayOopDesc 来 描述对象 头,arrayOopDesc 对象用来描述数组类型。instanceOopDesc 的定义在 Hotspot 源 码 中 的instanceOop.hpp 文件中,另外,arrayOopDesc 的定义对应 arrayOop.hpp。

instanceOop.hpp

从 instanceOopDesc 代码中可以看到 instanceOopDesc继承自 oopDesc,oopDesc 的定义载 Hotspot 源码中的oop.hpp 文件中

oop.hpp

       在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata,_mark 表示对象标记、属于 markOop 类型,也就是 Mark Word,它记录了对象和锁有关的信息,_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针。
MarkWord
在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,代码如下:

markOop.hpp

Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 种情况

Mark Word里存储的数据

2.3.2 为什么任何对象都可以实现锁?

       Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象oop/oopDesc 进行对应。
       线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。在 hotspot 源码的markOop.hpp 文件中,可以看到下面这段代码:

markOop.hpp

多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。

3. synchronized 锁的升级

       在分析 markword 时,提到了偏向锁、轻量级锁、重量级锁。在分析这几种锁的区别时,我们先来思考一个问题使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized 中,锁存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

3.1 偏向锁

3.1.1 偏向锁的基本原理

       当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了

3.1.2 偏向锁的获取

  1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
  2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
    a) 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
    b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且 把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
  3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
    a) 如果相等,不需要再次获得锁,可直接执行同步代码块
    b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

3.1.3 偏向锁的撤销

       偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设 置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后
继续执行同步代码块在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而
会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁

偏向锁.png

3.2 轻量级锁

3.2.1 轻量级锁的加锁过程

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

线程堆栈与对象头的状态

(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。

线程堆栈与对象头的状态

5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

3.2.2 轻量级锁的解锁过程

       轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁

轻量级锁及膨胀流程图.png

3.3 重量级锁

synchronized的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。

重量级锁的加锁的基本流程

总结:

synchronized特点:保证内存可见性、操作原子性
synchronized影响性能的原因:
1、加锁解锁操作需要额外操作;
2、互斥同步对性能最大的影响是阻塞的实现,因为阻塞涉及到的挂起线程和恢复线程的操作都需要转入内核态中完成(用户态与内核态的切换的性能代价是比较大的)

参考来源:
《Java并发编程的艺术》
《深入理解Java虚拟机》
https://www.cnblogs.com/RDaneelOlivaw/p/13970242.html
https://mp.weixin.qq.com/s?__biz=MzI3NzM2OTQ5Mg==&mid=2247484280&idx=1&sn=8de305338c5ab348c3e2a784084e4306&chksm=eb660483dc118d95e9bcde15a01103f818ed2fd399989f36dc2d57740a305e91cf986d4f5a64&scene=21#wechat_redirect

发表评论

后才能评论