使用 Synchronized 关键字

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

基本使用

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

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

image

一百个线程随机地为 count 加一,因为自增操做非原子性,多线程之间不正常的访问致使 count 最终的值不肯定,始终得不到预期的结果。缓存

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

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 的几个特性

一、可重入性

一个对象每每有多个方法,这些方法有的是同步的,有的是非同步的,那么若是一个线程已经得到了某个对象的锁并进入了其某个同步方法,而这个同步方法中还须要调用同一实例的另外一个同步方法,是否须要从新竞争锁?

这对于某些锁来讲,是须要从新竞争锁的,可是咱们的 synchronized 是「可重入的」,也就是说,若是当前线程得到了某个对象的锁,那么该对象的全部方法都是能够无需竞争锁式调用的。

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

二、内存可见性

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

image

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

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

image

主线程先得到 obj 的内置锁,而后启动 ThreadTwo 线程,该线程因为获取不到 obj 的锁而被阻塞,也就是它知道已经有其余线程在操做共享变量,因此等到本身得到锁的时候必定要从内存从新读一下共享变量。

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

固然有一点你们要注意,synchronized 修饰的代码块会在释放锁的时候刷新本身更改过的全局变量,可是另外一个线程要想看见,必须也从内存中从新读才行。而通常状况下,不是你加了 synchronized 线程就会从内存中读数据的,而只有它在竞争某把锁失败后,得知有其余线程正在修改共享变量,这样的前提下等到本身拥有锁以后才会从新去刷内存数据。

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

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

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


文章中的全部代码、图片、文件都云存储在个人 GitHub 上:

(github.com/SingleYam/o…)

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

image
相关文章
相关标签/搜索