一网打尽!synchronized关键字入门

2020年9月15日 第一次修录:最近很多小伙伴反馈了面试遇到的一些问题,做了一个整理。

2020年9月19日 第二次修录:看了一些资料,补充多线程可能带来的问题

目录

1.1 多线程一定快吗?

1.2 上下文切换

1.3 Java内存模型

1.4 主存与工作内存间的数据交互过程

 

2.1 多线程带来的可见性问题

2.2 多线程带来的原子性问题

2.3 多线程带来的有序性问题

2.4 多线程带来的活跃性问题

2.4.1 死锁的4个必要条件

2.5 性能问题

2.5.1 引起线程切换的几种方式

 

3.1 synchronized可重入特性

3.2 synchronized不可中断特性

3.3 观察汇编

4.1 monitor 监视器锁

4.2 monitor 竞争

4.3 monitor 等待

4.4 monitor 释放

 

5.1 CAS 介绍

5.2 synchronized锁升级过程

5.3 对象的布局

 

6.1 偏向锁

6.2 轻量级锁

6.3 自旋锁

6.4 锁消除

6.5 锁粗化

 

7.1 代码优化

 

8 面试题

8.1 为什么说Synchronized是一个悲观锁?乐观锁的实现原理又是什么?什么是CAS,它有什么特性?



1.1 多线程一定快吗?

有人做过这样一个实验:“一段代码,有两个方法对各自的属性进行累加操作,其中一个方法采用多线程,部分结果如下:

我们可以明显的看到,多线程不一定比单线程快

 

 1.2 上下文切换

单核处理器也支持多线程执行代码:

通过给线程分配时间片来实现这个机制。

时间片一般是几十毫秒,所以CPU需要通过不停地切换线程来执行。当前执行完一个时间片后会切换下一个任务。在切换前会保存当前任务的状态,可以恢复这个任务之前的状态。 任务从保存到再次被加载的过程就是一次上下文切换。

时间片:操作系统分配给每个正在运行的进程(线程)微观上的一段CPU时间

测试上下文切换:

当我们一直运行多线程程序时,发现CS飙升到1000以上。

 

1.3 Java内存模型

在深入学习synchronized关键字之前,有必要先了解一下Java的内存模型

Java Memory ModelJMM):

Java线程之间的通信由JMM来控制,其决定一个线程对共享变量的写入,何时对另外一个线程可见。

为了提高效率,线程之间的共享变量是存储在主存当中,每一个线程都有一个属于自己的本地内存。

如果线程A与线程B之间要通信,需要经历下面两步:

1 线程A把本地内存中更新过的共享变量,刷新到主存中。

2 线程B到主存中重新读取更新后的共享变量。

 

1.4 主存与工作内存间的数据交互过程

è¿éåå¾çæè¿°

  • lock     :作用于主存的变量,把一个变量标识为线程独占状态
  • unlock :作用于主存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read    :作用于主存变量,它把一个变量的值从主存传输到线程的工作内存中,以便随后的load动作使用
  • load     :作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use      :作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用的变量的值,就会使用到这个指令
  • assign :作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store    :作用于主存中的变量,它把一个从工作内存中一个变量的值传送到主存中,以便后续的write使用
  • write    :作用于主存中的变量,它把store操作从工作内存中得到的变量的值放入主存的变量中

JMM对这八种指令制定了如下规则:

  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

 

2.1 多线程带来的可见性问题

可见性:一个线程对主存的修改可以及时被其他线程观察到。

上图,线程一把flag属性读取到线程私有的本地内存中,值为false;线程二把flag属性修改为true,并且刷新到主内存当中,但是线程一不知道flag被修改了。

解决方案:如果有加同步的机制,则会有lockunlock操作,lock操作会使本地内存中的属性失效,从而去主内存中重新读取数据。

 

2.2 多线程带来的原子性问题

原子:同一个时刻只能有一个线程来对它进行操作。

  如果使用五个线程同时对变量 i 进行1000++ 操作,最后的结果并不一定等于5000

  反编译结果可以看出:

index ++ 一共涉及到4条指令;

假设线程一执行到步骤三时CPU切换线程。线程二执行步骤一,这时index的值还是等于0,因为线程一并没有执行步骤四就被切换上下文了。 等线程二执行完成,又切回到线程一,线程一会接着执行步骤四,并不会重新获取index的值,导致计算结果不正确。

 

当加上了synchronized同步机制之后, 会插入monitorentermonitorexit两条指令。

若线程一执行到步骤三,被切换到线程二,线程二执行monitorenter指令时会发现,这个对象已经被其他线程占用了,所以只能等待

当又切回到线程一时,线程一操作完整个步骤执行monitorexit来释放锁。此时线程二才可以获得锁。 这样就能保证原子性。

2.3 线程带来的有序性问题

有序Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序与编写的顺序不同。

一个典型的例子就是JVM中的类加载:

类从加载到JVM到卸载一共会经历五个阶段:加载、连接、初始化、使用、卸载。这五个过程的执行顺序是一定的,但是在连接阶段,也会分为三个过程,即验证、准备、解析阶段,这三个阶段的执行顺序不是确定的,通常交叉进行,在一个阶段的执行过程中会激活另一个阶段。

 

解决方案:加锁。

 

2.4 线程带来的活跃性问题

活跃性问题关注的是:某件事情是否会发生

典型的就是死锁问题:如果一组线程中的每个线程都在等待一个事件的发生,而这个事件只能由该组中正在等待的线程触发,这种情况会导致死锁。

2.4.1 死锁的4个必要条件

造成死锁的原因有四个,破坏其中一个即可破坏死锁

  • 互斥条件:在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程释放。
  • 请求和保持条件:进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持占有。
  • 不剥夺条件:进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 循环等待:在发生死锁时,必然存在一个进程对应的环形链。

 

2.5 性能问题

文章开头就提到过,在多线程中有一个非常重要的性能因素就是上下文切换。线程间的切换会涉及到一下几个步骤:

将CPU从一个线程切换到另一线程涉及挂起当前线程,保存其状态,例如寄存器,然后恢复到要切换的线程的状态,加载新的程序计数器。

 

2.5.1 引起线程切换的几种方式

  • 当前正在执行的任务完成,系统的CPU正常调度下一个需要运行的线程
  • 当前正在执行的任务遇到I/O等阻塞操作,线程调度器挂起此任务,继续调度下一个任务。
  • 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务。
  • 用户的代码挂起当前任务,比如线程执行sleep方法,让出CPU。
  • 使用硬件中断的方式引起

3.1 synchronized可重入特性

  可重入:一个线程可以多次执行synchronized重复获取同一把锁。

(synchronized底层锁对象中包含了一个计数器(recursions),记录线程获得了几次锁。 当我们同一个线程获得了锁,计数器则会+1,执行完同步代码块,计数器-1。 直到计数器的数量为0,就释放这个锁对象。

执行结果: 

在输出“同步代码块1之后,不需要等待锁释放,即可进入第二个同步代码块。这样的一个特性可以更好的封装代码(即:同步代码块中的代码,可以分成多个方法来写)。  

3.2 synchronized不可中断特性

  不可中断:线程二在等待线程一释放锁时,是不可被中断的。

  当一个线程获得锁之后,该线程不释放锁,后一个线程会一直被阻塞或等待。

  如果令线程一进入同步代码之后,一直持有锁,并且睡眠了;

  此时线程二启动去尝试获取锁,获取失败之后变成堵塞状态,即便强行中断线程二,最后看到线程二的状态仍是堵塞的。

 

3.3 观察汇编

观察对下列代码的java –p 结果:

monitorenter当我们进入同步代码块的时候会先执行monitorenter指令,每一个对象都会和一个monitor关联,监视器被占用时会被锁住,其他线程无法来获取该monitor。当其他线程执行monitorenter指令时,它会尝试去获取当前对象对应的monitor的所有权。

  两个重要成员变量:

  • owner: 当一个线程获取到该对象的锁,就把线程当前赋值给owner。 

  • recursions:会记录线程拥有锁的次数,重复获取锁当前变量会+1

monitorenter执行流程:

1. monitor的进入次数为0,线程可以进入,并将monitor进入的次数设为1,当前线程成为montiroowner

2. 若线程已拥有monitor的所有权,允许它重入monitor,进入一次次数+1

3. 若其他线程已经占有monitor,当前尝试获取monitor的线程会被阻塞,直到进入次数为变0,才能重新被再次获取。

  monitorexit能执行monitorexit指令的线程,一定是拥有当前对象的monitor所有权的。当执行monitorexit指令计数器减到为0时,当前线程就不再拥有monitor所有权。其他被阻塞的线程即可再一次去尝试获取这个monitor的所有权。

  上面编译出来的指令,其实monitoreexit是有两个:需要保证如果同步代码块执行抛出了异常,则也需要释放锁对象。 

 

  synchronized如果抛异常了,会不会释放锁对象,答案:会。

同步方法编译后指令:

同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰,会隐式调用monitorentermointorexit

4.1 monitor 监视器锁

每一个对象都会和一个monitor监视器关联,真正的锁都是靠monitor来完成。

 

源码:C++     http://hg.openjdk.java.net/jdk8/jdk8/hotspot/

 

java对象和monitor关联方式:

      对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding。对象头就包含了一个monitor的引用地址。

Monitor的成员属性

/src/share/vm/runtime/objectMonitor.hpp
  • _recursions:记录线程线程获取锁的次数,获取到锁该属性则会+1,退出同步代码块则-1

  • _owner:存储拥有monitor的所有权的线程。

  • _WaitSet:存储wait状态的线程。

  •  _cxq :当线程之间开始竞争锁,如果锁竞争失败后,则会加入_cxq链表中。

  • _EntryList:当新线程进来尝试去获取锁对象,又没有获取到对象的时候,则会存储到_EntryList当中。

 

4.2 monitor 竞争

竞争场景

  当多个线程执行同步代码块的时候,这个时候就会出现锁竞争。

  当线程执行同步代码块时,先执行monitorenter指令, 这个时候会调用interpreterRuntime.cpp中的函数

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
 
  // 是否用偏向锁
  if (UseBiasedLocking) {
    ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
  } else {
      // 重量级锁
    ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
  }

竞争方式

 

  对于重量级锁,monitorenter函数中会调用 ObjectSynchronizer::slow_enter ,最终调用到ObjectMonitor::enter

 

  基本操作流程如下:

  1 通过CAS尝试把monitor_owner属性设置为当前线程

  2 若之前设置的owner等于当前线程,说明重入,执行_recursions ++ 记录重入次数。

  3 若当前线程是第一次进入,设置_recursions = 1_owner = 当前线程,该线程成功获得锁并返回。

  4 如果获取锁失败,等待锁释放

 

4.3 monitor 等待

  锁竞争失败后,会调用EnterI 函数

 

  基本操作流程如下:

  1 进入EnterI后,先会再次尝试获取锁对象

  2 把当前线程封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ

  3 for循环中,通过CASnode节点push_cxq列表中(同一时刻可能有多个线程执行这个操作) 

  4 node节点push_cxq 列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待唤醒

  5 当前线程被唤醒时,会从挂起到点继续执行,通过TryLock再次尝试锁

 

4.4 monitor 释放

  释放monitor过程

 

  基本操作流程如下:

  1 退出同步代码块时会让_recursions - 1,当_recursions的值等于0的时候,说明线程释放了锁

  2 根据不同的策略(由QMode来指定),最终获取到需要被唤醒的线程(w表示)

  3 最后调用ExitEpilog函数中,最终由unpark来执行唤醒操作 

 

5.1 CAS 介绍

  Compare And Swap

  3个操作数:内存地址V旧的预期值A要更新的目标值B

  当内存地址V的值与预期值A相等时,将目标值B保存到内存当中,否则就什么都不做。 原子操作。

  CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,失败的线程并不会挂起,只是被告知这次竞争失败,可以再次尝试。

 

5.2 synchronized锁升级过程

  JDK1.5以前,synchronized是一个重量级锁,在1.6以后,对synchronized做了大量的优化,包含偏向锁、轻量级锁、适应性自旋、锁消除、锁粗化等。

 

  锁升级过程:无锁 à 偏向锁 à 轻量级锁 à 重量级锁。(只升不降)

 

  在了解各种锁的特性之前,需要先搞清楚对象在内存中的布局。

 

 5.3 对象的布局

  对象头(Header实例数据(Instance Data对齐填充(Padding

 

对象头:

  当一个线程尝试访问sync修饰的代码块时,它先要获得锁,这个锁对象存在于对象头中。

 

  Hotspot为例,对象头里面主要包含了Mark Word(字段标记)Klass Pointer (指针类型),如果对象是数组类型,还包含了数组的长度。

class Pointer 

用于存储对象的类型指针,JVM通过这个指针确定是哪个对象的实例。

 

实例数据

  类中定义的成员变量

 

对齐填充

仅仅只是占位符。由于Hotspot的自动内存管理系统要求对象起始地址必须是8字节的整倍数,当对象的实例数据部分没有对齐时,就需要通过对齐填充来补齐。

6.1 偏向锁

  有时锁不存在多线程竞争,总是由同一个线程多次获得。为了让线程获得的锁的代价更低,引入偏向锁。

 

  当一个线程访问同步代码块之后,会把Mark Word中的偏向锁标识由0改为1,以后该线程进入和退出时,不需要进行CAS操作来加()锁,只需要简单的测试一下对象头里是否存储着指向当先线程的偏向锁。

 

偏向锁的撤销

  等到竞争出现了才释放锁。

  注意:需要等到一个全局安全点来执行撤销

 

优点

  只有同一个线程来访问同步代码块时,效率很高

JVM关闭偏向锁指令:-XX:-UseBiasedLocking

6.2 轻量级锁

  JDK6中引入

 

  目的:在多线程交替执行同步代码块的情况下,尽量避免重量级锁引起的性能消耗,主要靠自旋来实现。

 

  如果多线程在同一时刻进入临界区(自旋次数超过某一阈值时,默认是10),会导致轻量级锁变为重量级锁。

 

栈帧

  JVM中内存划分中,栈中还包含了对象的各种方法,一个方法就相当于一个“栈桢”,可以存储一些内容。

 

 

原理

  线程在执行同步代码块之前:

  JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word 复制到锁记录当中(Displaced Mark Word

  JVM利用CAS尝试将对象的Mark Word更新为指向锁记录的指针。如果成功则该线程获得锁

  ③失败则需要判断当前对象的Mark Word是否指向当前线程,如果是则表示当线程已经持有对象的锁,执行同步代码快。如果不是只能说明该锁对象被其他线程占用,此时锁需要膨胀到重量级锁,后面的线程进入阻塞状态。

 

释放过程

  解锁的时候,会使用CAS操作将Displaced Mark Word替换回到对象头。如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁。 

 

6.3 自旋锁

  JDK1.4中引入

 

  循环去获取锁。当线程竞争锁失败之后,先自旋来尝试获取锁,如果锁被占用的时间很短,自旋的效果就较好。默认值是10次。

 

  适应性自旋锁

  自旋锁会带来一定的性能消耗,但又不确定自旋次数,此时可以使用适应性自旋锁,意味着自旋的时间由前一次在同一个锁的自旋时间及所得拥有者的状态来决定。 

 

6.4 锁消除

 public String getContent() {
        return new StringBuffer().append("a").append("b").append("c").toString();
    }

其中

@Override
 public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

append方法是同步的,但getContent方法,每次都是新new一个对象来进行操作。所以对不同的线程来说锁住的对象不同。

 

编译器(JIT)在运行时,会对一些被检测到不可能存在共享数据竞争的锁进行消除。

6.5 锁粗化

  public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append("a");
        }
    }

其中

 @Override
 public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

外层循环了100次,意味着要进入和退出锁各100次,此时JVM就会进行锁粗化。

append方法同步关键字去掉,扩大在外面来,就只需要进入和退出1次即可。

7.1 代码优化

 

减少sync的同步代码块的范围

 

  同步代码块精简,执行就会更快,可能轻量级锁、自旋锁就搞定了,不会升级为重量级锁。

 public static void main(String[] args) {

        StringBuffer sb = new StringBuffer();

        synchronized (sb) {

          System.out.println("a");

        }

}

 

降低sync锁的粒度

  本身没有任何业务相关的代码,锁的对象不设为同一个

 

读写分离

  读的时候不加锁,写入和删除的时候加锁,这样就可以保证多个线程同时来读取数据。

 

  HashTable容器竞争激烈的并发环境下,效率低是因为多个线程竞争同一把锁,假如容器有多把锁,每一把锁用于锁住容器中一部分数据,那么多线程访问容器里面不同的数据段的数据时,线程间不会存在锁竞争,从而有效提高并发访问率。ConcurrentHashMap

对后推荐一本书吧,网上评价非常高,"Java并发编程的圣经",关注我的个人公众号获取pdf版。

8 面试题

8.1 为什么说Synchronized是一个悲观锁?乐观锁的实现原理又是什么?什么是CAS,它有什么特性?

Synchronized 显 然 是 一 个 悲 观 锁 , 因 为 它 的 并 发 策 略 是 悲 观 的 :不 管 是 否 会 产 生 竞 争 , 任 何 的 数 据 操 作 都 必 须 要 加 锁 、 用 户 态 核 心 态 转换 、 维 护 锁 计 数 器 和 检 查 是 否 有 被 阻 塞 的 线 程 需 要 被 唤 醒 等 操 作 。

随 着 硬 件 指 令 集 的 发 展 , 我 们 可 以 使 用 基 于 冲 突 检 测 的 乐 观 并 发 策 略 。先 进 行 操 作 , 如 果 没 有 其 他 线 程 征 用 数 据 , 那 操 作 就 成 功 了 ;如 果 共 享 数 据 有 征 用 , 产 生 了 冲 突 , 那 就 再 进 行 其 他 的 补 偿 措 施 。 这 种乐 观 的 并 发 策 略 的 许 多 实 现 不 需 要 线 程 挂 起 , 所 以 被 称 为 非 阻 塞 同 步 。

乐 观 锁 的 核 心 算 法 是 CAS( Compareand Swap, 比 较 并 交 换 ) , 它 涉及 到 三 个 操 作 数 : 内 存 值 、 预 期 值 、 新 值 。 当 且 仅 当 预 期 值 和 内 存 值 相等 时 才 将 内 存 值 修 改 为 新 值 。这 样 处 理 的 逻 辑 是 , 首 先 检 查 某 块 内 存 的 值 是 否 跟 之 前 我 读 取 时 的 一样 , 如 不 一 样 则 表 示 期 间 此 内 存 值 已 经 被 别 的 线 程 更 改 过 , 舍 弃 本 次 操作 , 否 则 说 明 期 间 没 有 其 他 线 程 对 此 内 存 值 操 作 , 可 以 把 新 值 设 置 给 此块 内 存 。 CAS 具 有 原 子 性 , 它 的 原 子 性 由 CPU 硬 件 指 令 实 现 保 证 , 即 使 用 JNI 调 用 Native 方 法 调 用 由 C++ 编 写 的 硬 件 级 别 指 令 , JDK 中 提供 了 Unsafe 类 执 行 这 些 操 作 。 

猜你喜欢

转载自blog.csdn.net/HNU_Csee_wjw/article/details/108545688