Java并发编程基础--Synchronized

2020 年 05 月 07 日 阅读数: 27

线程的合理使用能够提升程序的处理性能,同时也带来了很多麻烦,例如有一个变量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的原理

用户头像

Java收录阁

关注

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

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

评论

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