手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

203 阅读22分钟

Synchronized原理

简介

synchronized想必大家都不陌生,用来解决线程安全问题的利器。同时也是Java高级程序员面试比较常见的面试题。下面会带大家彻底了解synchronized的实现。

内容导航

  1. 什么时候需要用Synchronized
  2. synchronized的使用
  3. synchronized的实现原理分析

什么时候需要用Synchronized

想必大家对synchronized都不陌生,主要作用是在多个线程操作共享数据的时候,保证对共享数据访问的线程安全性。

比如在下面这个图片中,两个线程对于i这个共享变量同时做i++递增操作,那么这个时候对于i这个值来说就存在一个不确定性,也就是说理论上i的值应该是2,但是也可能是1。而导致这个问题的原因是线程并行执行i++操作并不是原子的,存在线程安全问题。所以通常来说解决办法是通过加锁来实现线程的串行执行,而synchronized就是java中锁的实现的关键字。

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

 

synchronized在并发编程中是一个非常重要的角色,在JDK1.6之前,它是一个重量级锁的角色,但是在JDK1.6之后对synchronized做了优化,优化以后性能有了较大的提升(这块会在后面做详细的分析)。

先来看一下synchronized的使用

Synchronized的使用

synchronized有三种使用方法,这三种使用方法分别对应三种不同的作用域,代码如下

修饰普通同步方法

将synchronized修饰在普通同步方法,那么该锁的作用域是在当前实例对象范围内,也就是说对于 SyncDemosd=newSyncDemo();这一个实例对象sd来说,多个线程访问access方法会有锁的限制。如果access已经有线程持有了锁,那这个线程会独占锁,直到锁释放完毕之前,其他线程都会被阻塞

public SyncDemo{
 Object lock =new Object();
 //形式1
 public synchronized void access(){
 //
 }
 //形式2,作用域等同于形式1
 public void access1(){
 synchronized(lock){
 //
 }
 }
 //形式3,作用域等同于前面两种
 public void access2(){
 synchronized(this){
 //
 }
 }
}

修饰静态同步方法

修饰静态同步方法或者静态对象、类,那么这个锁的作用范围是类级别。举个简单的例子,

SyncDemo sd=SyncDemo();
SyncDemo sd2=new SyncDemo();} 

两个不同的实例sd和sd2, 如果sd这个实例访问access方法并且成功持有了锁,那么sd2这个对象如果同样来访问access方法,那么它必须要等待sd这个对象的锁释放以后,sd2这个对象的线程才能访问该方法,这就是类锁;也就是说类锁就相当于全局锁的概念,作用范围是类级别。

这里抛一个小问题,大家看看能不能回答,如果不能也没关系,后面会讲解;问题是如果sd先访问access获得了锁,sd2对象的线程再访问access1方法,那么它会被阻塞吗?

public SyncDemo{
 static Object lock=new Object();
 //形式1
 public synchronized static void access(){
 //
 }
 //形式2等同于形式1
 public void access1(){
 synchronized(lock){
 //
 }
 }
 //形式3等同于前面两种
 public void access2(){
 synchronzied(SyncDemo.class){
 //
 }
 }
}

步方法块

public SyncDemo{
 Object lock=new Object();
 public void access(){
 //do something
 synchronized(lock){
 //
 }
 }
}

通过演示3种不同锁的使用,让大家对synchronized有了初步的认识。当一个线程视图访问带有synchronized修饰的同步代码块或者方法时,必须要先获得锁。当方法执行完毕退出以后或者出现异常的情况下会自动释放锁。如果大家认真看了上面的三个案例,那么应该知道锁的范围控制是由对象的作用域决定的。对象的作用域越大,那么锁的范围也就越大,因此我们可以得出一个初步的猜想,synchronized和对象有非常大的关系。那么,接下来就去剖析一下锁的原理

Synchronized的实现原理分析

当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?

对象在内存中的布局

synchronized实现的锁是存储在Java对象头里,什么是对象头呢?在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

 

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

Hotspot虚拟机采用OOP-Klass模型来描述Java对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass用来描述对象实例的具体类型。Hotspot采用instanceOopDesc和arrayOopDesc来描述对象头,arrayOopDesc对象用来描述数组类型

instanceOopDesc的定义在Hotspot源码中的 instanceOop.hpp文件中,另外,arrayOopDesc的定义对应 arrayOop.hpp

class instanceOopDesc : public oopDesc {
 public:
 // aligned header size.
 static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }
 // If compressed, the offset of the fields of the instance may not be aligned.
 static int base_offset_in_bytes() {
 // offset computation code breaks if UseCompressedClassPointers
 // only is true
 return (UseCompressedOops && UseCompressedClassPointers) ?
 klass_gap_offset_in_bytes() :
 sizeof(instanceOopDesc);
 }
 static bool contains_field_offset(int offset, int nonstatic_field_size) {
 int base_in_bytes = base_offset_in_bytes();
 return (offset >= base_in_bytes &&
 (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
 }
};
#endif // SHARE_VM_OOPS_INSTANCEOOP_HPP

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

class oopDesc {
 friend class VMStructs;
 private:
 volatile markOop _mark;
 union _metadata {
 Klass* _klass;
 narrowKlass _compressed_klass;
 } _metadata;
 // Fast access to barrier set. Must be initialized.
 static BarrierSet* _bs;
 ...
}

在普通实例对象中,oopDesc的定义包含两个成员,分别是 _mark和 _metadata

_mark表示对象标记、属于markOop类型,也就是接下来要讲解的Mark World,它记录了对象和锁有关的信息

_metadata表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中Klass表示普通指针、 _compressed_klass表示压缩类指针

Mark Word

在前面我们提到过,普通对象的对象头由两部分组成,分别是markOop以及类元信息,markOop官方称为Mark Word

在Hotspot中,markOop的定义在 markOop.hpp文件中,代码如下

class markOopDesc: public oopDesc {
 private:
 // Conversion
 uintptr_t value() const { return (uintptr_t) this; }
 public:
 // Constants
 enum { age_bits = 4, //分代年龄
 lock_bits = 2, //锁标识
 biased_lock_bits = 1, //是否为偏向锁
 max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
 hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits, //对象的hashcode
 cms_bits = LP64_ONLY(1) NOT_LP64(0),
 epoch_bits = 2 //偏向锁的时间戳
 };
...

Mark word记录了对象和锁有关的信息,当某个对象被synchronized关键字当成同步锁时,那么围绕这个锁的一系列操作都和Mark word有关系。Mark Word在32位虚拟机的长度是32bit、在64位虚拟机的长度是64bit。

Mark Word里面存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为存储以下5种情况

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

32位虚拟机中的定义

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

64位虚拟机中的定义

锁标志位的表示意义

  1. 锁标识 lock=00 表示轻量级锁
  2. 锁标识 lock=10 表示重量级锁
  3. 偏向锁标识 biased_lock=1表示偏向锁
  4. 偏向锁标识 biased_lock=0且锁标识=01表示无锁状态

到目前为止,我们再总结一下前面的内容,synchronized(lock)中的lock可以用Java中任何一个对象来表示,而锁标识的存储实际上就是在lock这个对象中的对象头内。大家懂了吗?

其实前面只提到了锁标志位的存储,但是为什么任意一个Java对象都能成为锁对象呢?

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

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

在hotspot源码的 markOop.hpp文件中,可以看到下面这段代码。

ObjectMonitor* monitor() const {
 assert(has_monitor(), "check");
 // Use xor instead of &~ to provide one extra tag-bit check.
 return (ObjectMonitor*) (value() ^ monitor_value);
 }

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

锁的升级

前面提到了锁的几个概念,偏向锁、轻量级锁、重量级锁。在JDK1.6之前,synchronized是一个重量级锁,性能比较差。从JDK1.6开始,为了减少获得锁和释放锁带来的性能消耗,synchronized进行了优化,引入了 偏向锁和 轻量级锁的概念。所以从JDK1.6开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是:无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率,锁可以升级但是不能降级。

下面就详细讲解synchronized的三种锁的状态及升级原理

偏向锁

在大多数的情况下,锁不仅不存在多线程的竞争,而且总是由同一个线程获得。因此为了让线程获得锁的代价更低引入了偏向锁的概念。偏向锁的意思是如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。偏向锁可以通过 -XX:+UseBiasedLocking开启或者关闭

偏向锁的获取

偏向锁的获取过程非常简单,当一个线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,表示哪个线程获得了偏向锁,结合前面分析的Mark Word来分析一下偏向锁的获取逻辑

  1. 首先获取目标对象的Mark Word,根据锁的标识为和epoch去判断当前是否处于可偏向的状态
  2. 如果为可偏向状态,则通过CAS操作将自己的线程ID写入到MarkWord,如果CAS操作成功,则表示当前线程成功获取到偏向锁,继续执行同步代码块
  3. 如果是已偏向状态,先检测MarkWord中存储的threadID和当前访问的线程的threadID是否相等,如果相等,表示当前线程已经获得了偏向锁,则不需要再获得锁直接执行同步代码;如果不相等,则证明当前锁偏向于其他线程,需要撤销偏向锁。

CAS:表示自旋锁,由于线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说性能开销很大。同时,很多对象锁的锁定状态只会持续很短的时间,因此引入了自旋锁,所谓自旋就是一个无意义的死循环,在循环体内不断的重行竞争锁。当然,自旋的次数会有限制,超出指定的限制会升级到阻塞锁。

偏向锁的撤销

当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,撤销偏向锁的过程需要等待一个全局安全点(所有工作线程都停止字节码的执行)。

  1. 首先,暂停拥有偏向锁的线程,然后检查偏向锁的线程是否为存活状态
  2. 如果线程已经死了,直接把对象头设置为无锁状态
  3. 如果还活着,当达到全局安全点时获得偏向锁的线程会被挂起,接着偏向锁升级为轻量级锁,然后唤醒被阻塞在全局安全点的线程继续往下执行同步代码

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

偏向锁的获取流程图

轻量级锁

前面我们知道,当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销。偏向锁撤销以后对象会可能会处于两种状态

  1. 一种是不可偏向的无锁状态,简单来说就是已经获得偏向锁的线程已经退出了同步代码块,那么这个时候会撤销偏向锁,并升级为轻量级锁
  2. 一种是不可偏向的已锁状态,简单来说就是已经获得偏向锁的线程正在执行同步代码块,那么这个时候会升级到轻量级锁并且被原持有锁的线程获得锁

那么升级到轻量级锁以后的加锁过程和解锁过程是怎么样的呢?

轻量级锁加锁

  1. JVM会先在当前线程的栈帧中创建用于存储锁记录的空间(LockRecord)
  2. 将对象头中的Mark Word复制到锁记录中,称为Displaced Mark Word.
  3. 线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针
  4. 如果替换成功,表示当前线程获得轻量级锁,如果失败,表示存在其他线程竞争锁,那么当前线程会尝试使用CAS来获取锁,当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

轻量级锁加锁

轻量锁解锁

  1. 尝试CAS操作将所记录中的Mark Word替换回到对象头中
  2. 如果成功,表示没有竞争发生
  3. 如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁

一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于重量级锁状态,其他线程尝试获取锁时,都会被阻塞,也就是 BLOCKED状态。当持有锁的线程释放锁之后会唤醒这些现场,被唤醒之后的线程会进行新一轮的竞争

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

轻量级锁解锁

重量级锁

重量级锁依赖对象内部的monitor锁来实现,而monitor又依赖操作系统的MutexLock(互斥锁)

大家如果对MutexLock有兴趣,可以抽时间去了解,假设Mutex变量的值为1,表示互斥锁空闲,这个时候某个线程调用lock可以获得锁,而Mutex的值为0表示互斥锁已经被其他线程获得,其他线程调用lock只能挂起等待

为什么重量级锁的开销比较大呢?

原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的

并发编程

前言

作为一个合格的Java程序员,必须要对并发编程有一个深层次的了解,在很多互联网企业都会重点考察这一块。可能很多工作3年以上的Java程序员对于这一领域几乎没有太多研究。所以在接下来内容中,我会将并发编程整个领域由浅到深做非常全面的分析。

内容导航

  • 从操作系统的发展了解进程、线程模型
  • 线程的优势
  • 线程的生命周期
  • 线程的应用场景

了解进程、线程模型

每次学习一个新技术,我会先去了解这个技术的背景,这个过程看似浪费时间,其实在后续的学习过程中,能够促进理解很多问题。所以对于线程这个概念,我会先从操作系统讲起。因为操作系统的发展带来了软件层面的变革。 从多线程的发展来看,可以操作系统的发展分为三个历史阶段:

  • 真空管和穿孔卡片
  • 晶体管和批处理系统
  • 集成电路和多道程序设计

最早的计算机只能解决简单的数学运算问题,比如正弦、余弦等。运行方式:程序员首先把程序写到纸上,然后穿孔成卡片,再把卡片盒带入到专门的输入室。输入室会有专门的操作员将卡片的程序输入到计算机上。计算机运行完当前的任务以后,把计算结果从打印机上进行输出,操作员再把打印出来的结果送入到输出室,程序员就可以从输出室取到结果。然后,操作员再继续从已经送入到输入室的卡片盒中读入另一个任务重复上述的步骤。

操作员在机房里面来回调度资源,造成计算机存在大量的空闲状态 。而当时的计算机是非常昂贵的,人们为了减少这种资源的浪费。就采用了 批处理系统来解决

批处理操作系统的运行方式:在输入室收集全部的作业,然后用一台比较便宜的计算机把它们读取到磁带上。然后把磁带输入到计算机,计算机通过读取磁带的指令来进行运算,最后把结果输出磁带上。批处理操作系统的好处在于,计算机会一直处于运算状态,合理的利用了计算机资源。(运行流程如下图所示)

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

(注:此图来源于现代操作系统)

批处理操作系统虽然能够解决计算机的空闲问题,但是当某一个作业因为等待磁盘或者其他I/O操作而暂停,那CPU就只能阻塞直到该I/O完成,对于CPU操作密集型的程序,I/O操作相对较少,因此浪费的时间也很少。但是对于I/O操作较多的场景来说,CPU的资源是属于严重浪费的。

多道程序设计的出现解决了这个问题,就是把内存分为几个部分,每一个部分放不同的程序。当一个程序需要等待I/O操作完成时。那么CPU可以切换执行内存中的另外一个程序。如果内存中可以同时存放足够多的程序,那CPU的利用率可以接近100%。 在这个时候,引入了第一个概念- 进程, 进程的本质是一个正在执行的程序,程序运行时系统会创建一个进程,并且给每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰。同时,在CPU对进程做时间片的切换时,保证进程切换过程中仍然要从进程切换之前运行的位置出开始执行。所以进程通常还会包括程序计数器、堆栈指针。

有了进程以后,可以让操作系统从宏观层面实现多应用并发。而并发的实现是通过CPU时间片不断切换执行的。对于单核CPU来说,在任意一个时刻只会有一个进程在被CPU调度

有了进程以后,为什么还会出现线程呢?

在一个应用进程中,会存在多个同时执行的任务,如果其中一个任务被阻塞,将会引起不依赖该任务的任务也被阻塞。举个具体的例子来说,我们平常用word文档编辑内容的时候,都会有一个自动保存的功能,这个功能的作用是,当计算机出现故障的情况下如果用户未保存文档,则能够恢复到上一次自动保存的点。假设word的自动保存因为磁盘问题导致写入较慢,势必会影响到用户的文档编辑功能,直到磁盘写入完成用户才可编辑,这种体验是很差的。如果我们把一个进程中的多个任务通过线程的方式进行隔离,那么按照前面提到的进程演进的理论来说,在单核心CPU架构中可以通过CPU的时间片切换实现线程的调度充分利用CPU资源以达到最大的性能。

我们用了比较长的篇幅介绍了进程、线程发展的历史。总的来说是人们对于计算机的要求越来越高;对于计算机本身的资源的利用率也在不断提高。

线程的优势

前面分析了线程的发展历史,这里简单总结一下线程有的优势如下

  • 线程可以认为是轻量级的进程,所以线程的创建、销毁要比进程更快
  • 从性能上考虑,如果进程中存在大量的I/O处理,通过多线程能够加快应用程序的执行速度(通过CPU时间片的快速切换)。
  • 由于线程是CPU的最小调度单元,所以在多CPU架构中能够实现真正的并行执行。每一个CPU可以调度一个线程

这里有两个概念很多人没有搞明白,就是并行和并发

并行:同时执行多个任务,在多核心CPU架构中,一个CPU核心运行一个线程,那么4核心CPU,可以同时执行4个线程

并发:同处理多个任务的能力,通常我们会通过TPS或者QPS来表示某某系统支持的并发数是多少。

总的来说,并行是并发的子集。也就是说我们可以写一个拥有多线程并行的程序,如果在没有多核心CPU来执行这些线程,那就不能以并行的方式来运行程序中的多个线程。所以并发程序可以是并行的,也可以不是。Erlang之父Joe Armstrong通过一张图型的方式来解释并发和并行的区别,图片如下

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

 

线程的生命周期

线程是存在生命周期的,从线程的创建到销毁,可能会经历6种不同的状态,但是在一个时刻线程只能处于其中一种状态

  • NEW:初始状态,线程被创建时候的状态,还没有调用start方法
  • RUNNABLE:运行状态,运行状态包含就绪和运行两种状态,因为线程启动以后,并不是立即执行,而是需要通过调度去分配CPU时间片
  • BLOCKED:阻塞状态,当线程去访问一个加锁的方法时,如果已经有其他线程获得锁,那么当前线程会处于阻塞状态
  • WAITING:等待状态,设置线程进入等待状态等待其他线程做一些特定的动作进行触发
  • TIME_WAITING:超时等待状态,和WAITING状态的区别在于超时以后自动返回
  • TERMINATED:终止状态,线程执行完毕

下图整理了线程的状态变更过程及变更的操作,每一个具体的操作原理,我会在后续的文章中进行详细分析。

手握Synchronized原理搞懂并发编程,阿里面试官:快到碗里来

 

这里有一个问题大家可能搞不明白,BLOCKED和WAITING这两个阻塞有什么区别?

  • BLOCKED状态是指当前线程在等待一个获取锁的操作时的状态。
  • WAITING是通过Object.wait或者Thread.join、LockSupport.park等操作实现的
  • BLOCKED是被动的标记,而WAITING是主动操作
  • 如果说得再深入一点,处于WAITING状态的线程,被唤醒以后,需要进入同步队列去竞争锁操作,而在同步队列中,如果已经有其他线程持有锁,则线程会处于BLOCKED状态。所以可以说BLOCKED状态是处于WAITING状态的线程重新唤醒的必经的状态

线程的应用场景

线程的出现,在多核心CPU架构下实现了真正意义上的并行执行。也就是说,一个进程内多个任务可以通过多线程并行执行来提高程序运行的性能。那线程的使用场景有哪些呢?

  • 执行后台任务,在很多场景中,可能会有一些定时的批量任务,比如定时发送短信、定时生成批量文件。在这些场景中可以通过多线程的来执行
  • 异步处理,比如在用户注册成功以后给用户发送优惠券或者短信,可以通过异步的方式来执行,一方面提升主程序的执行性能;另一方面可以解耦核心功能,防止非核心功能对核心功能造成影响
  • 分布式处理,比如fork/join,将一个任务拆分成多个子任务分别执行
  • BIO模型中的线程任务分发,也是一种比较常见的使用场景,一个请求对应一个线程

合理的利用多线程,可以提升程序的吞吐量。同时,还可以通过增加CPU的核心数来提升程序的性能,这就体现了伸缩性的特点

阿里P7技能要求

1. 三年以上大规模分布式系统应用架构设计与研发经验,扎实的Java编程基础,精通Java EE、SOA、OSGI等相关技术;对各种开源的框架如Spring、Hibernate等有深入的了解,对框架本身有过开发或重构者可优先考虑;

2. 三年以上大型数据库如oracle使用经验,精通unix/linux操作系统,对常用命令运用娴熟,能够根据实际需要快速编写shell脚本;

3. 具备良好的识别和设计通用框架及模块的能力,具备系统调优、性能调优等技能,对疑难技术问题具备较强的排查能力;

4. 对技术有激情,喜欢钻研,能快速接受和掌握新技术,有较强的独立、主动的学习能力,良好的沟通表达能力和团队协作能力;

5. 专注于技术、对业界的最新技术发展动态有比较密切的关注,同时对电子商务、O2O行业有较深刻的理解和敏感的触觉,能前瞻性提出行业技术解决方案;