写点什么

Java 并发编程基础 --Synchronized

用户头像
Java收录阁
关注
发布于: 2020 年 05 月 07 日

线程的合理使用能够提升程序的处理性能,同时也带来了很多麻烦,例如有一个变量 i,假如一个线程去访问这个变量并进行修改,这个时候对于数据的访问和修改是没有任何问题的;但是如果多个线程对同一个变量进行修改,就会存在数据安全性的问题。

对于线程安全,本质上是管理对于数据状态的访问,而且这个状态通常是共享的,可变的。共享是指这个数据变量可以被多个线程访问;可变是指这个变量的值在他的生命周期内是可以改变的。


一个对象是否线程安全,取决于这个对象是否会被多线程访问,以及程序中是如何使用这个对象的。所以,如果多个线程访问同一个共享对象,在不需要额外的同步以及调用端不用做其他协调情况下,这个共享对象的状态依然是正确的,那就说明这个对象是线程安全的


如果我们能够有一种方法使得线程的并行变成串行,是不是就解决这个问题了?所以我们最先想到的应该是锁,Java 提供加锁的方式就是 synchronized 关键字。


在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重了。本文详细介绍 Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。


Synchronized 有三种方式加锁:

  1. 修饰实例方法:作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  2. 修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  3. 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁


不同的修饰类型,代表着锁的控制力度


锁是如何存储的

要实现多线程的互斥特性,这把锁需要哪些特性?

  1. 锁需要有一个东西来表示,比如获得锁是什么状态、无锁状态是什么状态

  2. 这个状态需要对多个线程共享


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


JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。


synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit。


Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。32 位 JVM 的 Mark Word 的默认存储结构如下表所示。


在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 4 种数据,如下表所示:


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

  1. 首先,Java 中的每一个对象都派生自 Object 类,而每个 Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc 进行对应

  2. 线程在获取锁的时候,实际上就是获取一个监视器对象 monitor,monitor 可以认为是一个同步对象,所有的 Java 对象都天生携带 monitor


Synchronized 锁的升级

上面我们提到了偏向锁、轻量级锁、重量级锁,在分析这几种锁的区别时,我们先思考一个问题:使用锁能实现数据的安全性,但会带来性能的下降;不适用锁能够基于线程并行提升程序性能,但不能确保线程安全性;这两者之间似乎没有办法达到既满足性能也满足安全性的要求。

hotspot 虚拟机的作者调查发现,大部分情况下加锁的代码不仅仅不存在线程竞争,而且总是由同一个线程多次获得,所以基于这样一个几率,在 JDK1.6 之后对 synchronized 做了优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁和重量级锁的概念。锁存在的四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁;锁的状态根据竞争的激烈程度从低到高不断升级。


偏向锁

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


偏向锁的获取

  1. 首先获取锁对象的 markword,判断是否处于可偏向状态(biased_lock=1、且 ThreadId 为空)

  2. 如果是可偏向状态,则通过 CAS 操作把当前线程 ID 写到 MarkWord 中

a.) 如果 CAS 成功,说明已经获得了对象的偏向锁,接着执行同步代码块

b.) 如果 CAS 失败,说明已经有其它线程获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已经获得偏向锁的线程,并 把它持有的锁升级为轻量级锁

  1. 如果是已偏向状态,需要检查 MarkWord 中存储的线程 ID 是否和当前线程的 ID 相等:

a.) 如果相等,不需要获得锁,直接执行同步代码块

b.) 如果不相等,说明偏向锁偏向其它线程,需要撤销偏向锁并升级到轻量级锁


偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程 1 演示了偏向锁初始化的流程,线程 2 演示了偏向锁撤销的流程。


关闭偏向锁

偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。


轻量级锁

锁升级为轻量级锁后,对象的 MarkWord 也会进行相应的变化。升级为轻量级锁的过程如下:

  1. 线程在自己的栈帧中创建锁记录 Lock Record

  2. 将锁对象的对象头中的 MarkWord 复制到线程刚刚创建的锁记录中

  3. 将锁记录中的 Owner 指向锁对象

  4. 将锁对象的对象头中的 MarkWord 替换为指向所记录的指针




轻量级锁在加锁过程中用到了自旋锁;所谓的自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程阻塞,直到那个获得锁的线程释放了锁之后,这个等待的线程可以马上获得锁。注意,锁在原地循环的时候是会消耗 CPU 的,相当于在执行一个啥也没有的 for 循环。所以轻量级锁适合那些同步代码块执行的很快的场景,这样线程原地等待很短的时间就能获得锁了。


自旋锁的使用其实也是有一定的概率背景,在大部分同步代码块执行很短的情况下,通过看似无意义的循环反而能提升锁的性能;但是自旋必须要有一定的条件限制,否则如果一个线程执行同步代码块的时间很长,那么这个等待线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改默认次数。


在 JDK 1.6 之后引入了自适应自旋锁, 自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待的时间持续相对更长的时间;如果对于某个锁,自旋很少成功过,那在尝试获取这个锁的时候可能将自旋这个步骤省略掉,直接阻塞线程,避免浪费 CPU 资源。


轻量级锁的释放

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


重量级锁

当轻量级锁膨胀成重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。通过 javap -v xxxx 命令查看到类文件的字节码:


加了同步代码块之后,在字节码中会看到 monitorenter 和 mointorexit。每一个 Java 对象都会与一个监视器 monitor 关联,我们可以把它理解成一把锁,当一个线程想要执行一段被 synchronized 关键字修饰的同步方法或者代码块时,该线程需要先获得到 synchronized 修饰的对象对应的 monitor。

monitorenter 表示去获取一个对象监视器,monitorexit 表示释放 monitor 监视器的所有权,是的其它被阻塞的线程可以尝试去获得这个监视器。重量级锁加锁的基本流程:


任意线程对 Object 的访问,首先要获得 Object 的监视器,如果获取失败线程就会进入同步队列,线程状态变成 BLOCKED;当访问 Object 的前驱(获得锁的线程)释放了锁,则该释放操作会唤醒阻塞在阻塞队列中的线程,使其重新尝试对监视器的获取。


锁的优缺点对比


假如有下面这样一个同步代码块,存在 Thread1 和 Thread2 等多个线程:

synchronized(lock) {	// do something}
复制代码


情况一:只有 Thread 1 会进入临界区

情况二:Thread 1 和 Thread 2 交替进入临界区,竞争不激烈

情况三:Thread 1/Thread 2/Thread 3....同时进入临界区,竞争激烈


偏向锁:当 Thread 1 进入临界区,JVM 会将 lockObject 对象头的 MarkWord 的锁标志位设为 01,同时会用 CAS 操作把 Thread 1 的 ID 记录到 Mark Word 中,此时进入偏向模式;所谓"偏向",指的是这个锁会偏向于 Thread 1,若接下来没有其它线程进入临界区,则 Thread 1 再出入临界区无需再次执行任何同步操作,也就是说若只有 Thread 1 进入临界区,实际上只有 Thread 1 初次进入临界区的时候会执行同步操作,以后再次出入临界区都不会有同步操作带来的开销。


轻量级锁:偏向锁的场景太过于理想化,更多的时候 Thread 2 也会尝试进入临界区。如果 Thread 2 也进入临界区但是 Thread 1 还没有执行完同步代码块,会暂停 Thread 1 并升级锁到轻量级锁;Thread 2 会通过自旋再次尝试以轻量级锁的方式获取锁。


重量级锁:如果 Thread 1 和 Thread 2 正常交替执行,那么轻量级锁基本能够满足需求。但是如果 Thread 1 和 Thread 2 同时进入临界区,那么轻量级锁就会膨胀成重量级锁,以为着 Thread 1 获得了重量级锁的情况下,Thread 2 就会被阻塞


synchronized 结合 Java Object 对象中的 wait、notify、notifyAll

前面我们在了解 synchronized 的时候,发现被阻塞的线程什么时候被唤醒取决于获得锁的线程什么时候执行完同步代码块并且释放锁,那有什么办法能做到显示控制呢?我们需要借助一个信号机制:在 Object 对象中,提供了 wait、notify、notifyAll 方法,可用于控制线程的状态


wait: 表示持有对象锁的线程 A 准备释放对象锁的权限,释放 CPU 资源并进入等待状态。

notify: 表示持有对象锁的线程 A 准备释放对象锁权限,通知 JVM 唤醒某个竞争该对象锁的线程 x。线程 A synchronized 代码块执行结束并释放了锁之后,线程 x 直接获得对象锁权限,其它竞争线程继续等待(即使线程 x 同步完毕,释放对象锁,其它竞争线程仍然处于等待状态,知道有新的 notify 或者 notifyAll 被调用)

notifyAll: notifyAll 和 notify 的区别在于 notifyAll 会唤醒所有竞争同一个对象锁的线程,当已经获得对象锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限


wait/notify 的原理


发布于: 2020 年 05 月 07 日阅读数: 180
用户头像

Java收录阁

关注

士不可以不弘毅,任重而道远 2020.04.30 加入

喜欢收集整理Java相关技术文档的程序员,欢迎关注同名微信公众号 Java收录 阁获取更多文章

评论

发布
暂无评论
Java并发编程基础--Synchronized