使用 Synchronized 关键字来解决并发问题是最简单的一种方式,我们只需要使用它修饰需要被并发处理的代码块、方法或字段属性,虚拟机自动为它加锁和释放锁,并将不能获得锁的线程阻塞在相应的阻塞队列上。

基本使用

我们在上篇文章介绍线程的基本概念时,提到了多线程的好处,能够最大化 CPU 使用效率、更友好交互等等,但是也提出了它带来的问题,比如竞态条件、内存可见性问题。

我们引用上篇文章中的一个案例:

image

一百个线程随机地为 count 加一,由于自增操作非原子性,多线程之间不正常的访问导致 count 最终的值不确定,始终得不到预期的结果。

使用 synchronized 即刻就能解决,看代码:

image

代码稍作修改,现在的程序无论你运行多少次,或者你增大并发量,最后 count 的值总是正确的 100 。

大概什么意思呢?

我们的 JAVA 中,对于每个对象都有一把『内置锁』,而 synchronized 中的代码在被线程执行之前,会去尝试获取一个对象的锁,如果成功,就进入并顺利执行代码,否则将会被阻塞在该对象上。

除此之外,synchronized 除了可以修饰代码块,还可以直接修饰在方法上,例如:

public synchronized void addCount(){......}
public static synchronized void addCount(){......}

这是两种不同的使用方式,前一种是使用 synchronized 修饰的实例方法,那么 synchronized 使用的就是当前方法调用时所属的那个实例的『内置锁』。也就是说,addCount 方法调用前会去尝试获取调用实例对象的锁。

而后一种 addCount 方法是一个静态方法,所以 synchronized 使用的就是 addCount 所属的类对象的锁。

synchronized 的使用方式还是很简单的,什么时候加锁,什么时候释放锁都不需要我们操心,被 JVM 封装好了,下面我们就来简单看看 JVM 是如何实现这种间接锁机制的。

基本实现原理

我们先看一段简单的代码:

public class TestAxiom {
    private int count;

    @Test
    public void test() throws InterruptedException {
        synchronized (this){
            count++;
        }
    }
}

这是一段非常简单的代码,使用 synchronized 修饰代码块,保护 count++ 操作。现在我们反编译一下:

image

可以看到,在执行 count++ 指令之前,编译器加了一条 monitorenter 指令,count++ 指令执行结束时又加了一条 monitorexit 指令。准确意义上来说,这就是两条加锁的释放锁的指令,具体细节我们稍后再看。

除此之外,我们的 synchronized 方法在反编译后并没有这两条指令,但是编译器却在方法表的 flags 属性中设置了一个标志位 ACC_SYNCHRONIZED。

这样,每个线程在调用该方法之前都会检查这个状态位是否为 1,如果状态为 1 说明这是一个同步方法,需要首先执行 monitorenter 指令去尝试获取当前实例对象的内置锁,并在方法执行结束执行 monitorexit 指令去释放锁。

其实本质上是一样的,只是 synchronized 方法是一种隐式的实现。下面我们来看一看这个内置锁的具体细节。

Java 中一个对象主要由以下三种类型数据组成:

  • 对象头:也称 Mark Word,主要存储的对象的 hash 值以及相关锁信息。
  • 实例数据:保存的当前对象的数据,包括父类属性信息等。
  • 填充数据:这部分是应 JVM 要求,每个对象的起始地址必须是 8 的倍数,所以如果当前对象不足 8 的倍数字节时用于字节填充。

我们的『内置锁』在对象头里面,而 Mark Word 的一个基本结构是这样的:

image

先不去管什么是,轻量锁,重量锁,偏向锁,自旋锁,这是虚拟机一种锁优化机制,通过锁膨胀来优化性能,这一点的细节我们以后再介绍,你先把它们统一理解为一把锁。

其中,每把锁会有一个标志位用于区分锁类型,和一个指向锁记录的指针,也就是说锁指针会关联另一种结构,Monitor Record。

image

Owner 字段存储的是拥有当前锁的线程唯一标识号,当某个线程拥有了该锁之后就会把自己的线程号写入这个字段中。如果某个线程发现这里的 Owner 字段不是 null 也不是自己的线程号,那么它将会被阻塞在 Monitor 的阻塞队列上直至某个线程走出同步代码块并发起唤醒操作。

总结一下,被 synchronized 修饰的代码块或者方法在编译器会被额外插入两条指令,monitorenter 会去检查对象头锁信息,对应到一个 Monitor 结构,如果该结构的 Owner 字段已经被占用了,那么当前线程将会被阻塞在 Monitor 的一个阻塞队列上,直到占有锁的线程释放了锁并唤起一波新的锁竞争。

synchronized 的几个特性

1、可重入性

一个对象往往有多个方法,这些方法有的是同步的,有的是非同步的,那么如果一个线程已经获得了某个对象的锁并进入了其某个同步方法,而这个同步方法中还需要调用同一实例的另一个同步方法,是否需要重新竞争锁?

这对于某些锁来说,是需要重新竞争锁的,但是我们的 synchronized 是「可重入的」,也就是说,如果当前线程获得了某个对象的锁,那么该对象的所有方法都是可以无需竞争锁式调用的。

原因也很简单,monitorenter 指令找到 Monitor,查看了 Owner 字段的值等于当前线程的线程号,于是将 Nest 字段增加一,表示当前线程多次持有该对象的锁,每调用一次 monitorexit 都会减一 Nest 的值。

2、内存可见性

引用上篇文章的一个例子:

image

线程 ThreadTwo 不停的监听 flag 的值,而我们主线程对 flag 进行了修改,由于内存可见性,ThreadTwo 看不见,于是程序一直死循环。

某种意义上,synchronized 是可以解决这类内存可见性问题的,修改代码如下:

image

主线程先获得 obj 的内置锁,然后启动 ThreadTwo 线程,该线程由于获取不到 obj 的锁而被阻塞,也就是它知道已经有其他线程在操作共享变量,所以等到自己获得锁的时候一定要从内存重新读一下共享变量。

而我们的主线程会在释放锁的时候将私有工作内存中所有的全局变量的值刷新到内存空间,这样其实就实现了多线程之间的内存可见性。

当然有一点大家要注意,synchronized 修饰的代码块会在释放锁的时候刷新自己更改过的全局变量,但是另一个线程要想看见,必须也从内存中重新读才行。而一般情况下,不是你加了 synchronized 线程就会从内存中读数据的,而只有它在竞争某把锁失败后,得知有其他线程正在修改共享变量,这样的前提下等到自己拥有锁之后才会重新去刷内存数据。

你也可以试试,让 ThreadTwo 线程不去竞争 obj 这把锁,而随便给它一个对象,结果依然会是死循环,flag 的值只会是 ThreadTwo 刚启动时从内存读入的初始数据的缓存版。

但是说实话,解决内存可见性而使用 synchronized 代价太高,需要加锁和释放锁,甚至还需要阻塞和唤醒线程,我们一般使用关键字 volatile 直接修饰在变量上就可以了,这样对于该变量的读取和修改都是直接映射内存的,不经过线程本地私有工作内存的。

关于 synchronized 关键字我们暂时先介绍到这,后续还会涉及到它的,我们还要介绍近几个 JDK 版本对于 synchronized 的优化细节,包括自旋锁,偏向锁,重量级锁之间的锁膨胀机制,也是这种优化使得现在的 synchronized 性能不输于 Lock。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:OneJavaCoder,所有文章都将同步在公众号上。

image

版权声明:本文为yangming1996原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/yangming1996/p/9541755.html