> 文档中心 > 线程共享模型----之----管程(一)

线程共享模型----之----管程(一)

目录:

线程共享模型总目录

1.1 synchronized锁

1.2 变量的线程安全分析

1.3 Monitor 概念(管程)

1.4 synchronized 原理进阶

1.5 wait & notify

1.6 Park & Unpark

1.7 活跃性

1.8 ReentrantLock(可重入锁)

1.9 经典面试题


1.1 synchronized锁

在介绍 synchronized 之前,先介绍两个概念:

  • 临界区(Critical Section):一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。
  • 竞态条件(Race Condition):多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。

为了避免临界区的竞态条件发生,有多种手段可以达到目的:

  • 阻塞式的解决方案: synchronized Lock
  • 非阻塞式的解决方案:原子变量

这里我们先介绍 synchronized: 俗称 对象锁 ,它采用互斥的方式让同一 时刻至多只有一个线程能持有 对象锁 ,其它线程再想获取这个 对象锁 时就会阻塞(Blocked)住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

语法:

synchronized(锁对象) // 线程1, 线程2(blocked){     临界区代码}

若线程1持有此锁对象,则线程2要想访问此临界区代码,需要等待线程1执行完synchronized中的代码后归还此锁对象,并竞争到此锁对象后,才能访问临界区的代码,等待过程中线程处于阻塞(blocked)状态。synchronized 实际是用对象锁保证了临界区内代码的原子性,即临界区内的代码对外是不可分割的,不会被线程切换所打断。

【修饰方法的 synchronized】

修饰普通方法的 synchronized:锁对象是此实例对象(this)

class Test{     public synchronized void test() {      }}// 等价于class Test{     public void test() {  synchronized(this) {   }     }}

修饰静态方法的 synchronized:锁对象是此类对象(类名.class)

class Test{     public synchronized static void test() {     }}// 等价于class Test{     public static void test() {  synchronized(Test.class) {   }     }}

没有被 synchronized 修饰的方法,是无法保证方法原子性的。

1.2 变量的线程安全分析

【什么是线程安全?什么是线程不安全?】

线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可使用。不会出现数据不一致或者数据污染。
线程不安全就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据

【成员变量和静态变量是否线程安全?】

如果它们没有共享,则线程安全。如果它们被共享了,根据它们的状态是否能够改变,又分两种情况:如果只有读操作,则线程安全;如果有读写操作,则这段代码是临界区,需要考虑线程安全。

【局部变量是否线程安全?】

局部变量是线程安全的, 方法中的局部变量 ,会在每个线程的栈帧内存中都被创建一份,因此不存在共享 。但局部变量引用的对象则未必:如果该对象没有逃离方法的作用范围,它是线程安全的;如果该对象逃离方法的作用范围,需要考虑线程安全。 当 list 是成员变量时:
当 list 是局部变量时:

因为虚拟机栈是线程私有的,其栈帧中的局部变量表也是每个线程独有一份的,所以局部变量是线程安全的。但如果其发生了逃逸,则就不是线程安全的了。  

这里关于虚拟机栈(帧)相关知识不懂的可以参考 JVM运行时数据区结构及原理 

【常见线程安全的类有哪些?】

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类 

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。

注意:这些类中的单个方法都是原子的,但是他们多个方法组合起来使用就不是原子的了。例如:

Hashtable table = new Hashtable();// 线程1,线程2if( table.get("key") == null) {     table.put("key", value);}

Hashtable 的 get 方法和 put 方法都是线程安全的,但是当他们组合起来使用时,就不是线程安全的了,因为他们只对自身内部的具体实现加了锁。
get 方法内部源码剖析(put 方法类似):

1.3 Monitor 概念

在介绍 Monitor 之前,我们先来了解一下对象头:
普通对象的对象头包含两部分:

  • 运行时元数据(Mark Word):哈希值、GC年龄分代、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
  • 类型指针:指向类元数据,确定该对象所属的类型
  • 如果是数组,还需要记录数组的长度

Mark Word 的结构:

 具体可以去基于JVM的对象的实例化过程了解一下,这里就不细讲了。

Monitor :

        被翻译为监视器或管程,由操作系统提供,相当于临界区的监管者。每一个Java对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针。

Monitor 结构如下:

  • Owner 记录当前正在访问此临界区域的线程(Thread-2)
  • EntryList 记录当前正在排队而进入阻塞状态的线程(Thread-3、4、5),当 Thread-2 执行完同步代码块中的内容后,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平
  • Waiting 中的 Thread-0、1 线程是之前获得过锁,但条件不满足进入 WAITING (阻塞)状态的线程,WAITING 线程会在 Owner 线程调用 notify() 或 notifyAll() 时唤醒,但唤醒之后并不意味着立刻获得锁,仍需进入 EntryList 重新竞争。

注意:synchronized 必须是进入同一个对象的 monitor 才有上述结果。不加 synchronized 的对象不会关联监视器,不遵守以上规则。

1.4 synchronized 原理进阶

【轻量级锁】

如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是 synchronized,加锁时会优先考虑轻量级锁,如果失败了(有竞争)才会考虑重量级锁。

假设有两个方法同步块,利用同一个对象加锁:

static final Object obj = new Object();public static void method1() {    synchronized (obj) { // 同步块 A method2();    }}public static void method2() {    synchronized (obj) { // 同步块 B    }}
  • 创建锁记录(Lock Record)对象:每个线程的栈帧中都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word。让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 中的 Mark Word ,将 Mark Word 的值存入锁记录中。
  • 如果 cas 替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下:
  • 如果 cas 替换失败,有两种情况:
    • 如果是其他线程已经持有了该 Object 的轻量级锁,这是表明有竞争,进入锁膨胀过程。
    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
  • 当退出 synchronized 代码块(解锁时),如果有取值为 null 的锁记录,表示有重入,这是重置锁记录,表示重入计数减一
  • 当退出 synchronized 代码块(解锁时),锁记录的值不为 null,这是使用 cas 将 Mark Word 的值恢复给对象头。成功:则解锁成功;失败:说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。

【锁膨胀】

如果在尝试加轻量级锁的过程中, cas 操作无法成功,这时一种情况就是有其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变成重量级锁。

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁,这时 Thread-1 加轻量级锁失败,进入锁膨胀流程:即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null ,唤醒 EntryList 中的 BLOCKED 线程。

【自旋优化】

重量级锁竞争的时候,还可以使用自旋来优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。自旋会占用 CPU 的时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。

【偏向锁】

轻量级锁在没有竞争时(就自己这一个线程),每次重入仍然需要执行 cas 操作。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 cas 时将线程 ID设置到对象头的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 cas 。只要以后不发生竞争,这个对象就归该线程所有。

回顾对象头格式:

一个对象创建时:

  • 如果开启了偏向锁(默认开启),那么对象创建后,Mark Word 值为 0x05 即最后三位为 101,这时它的 thread、epoch、age 都为 0。
  • 偏向锁默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX: BiasedLockingStartupDelay=0 来禁用延迟。添加 VM 参数 -XX: -UseBiasedLocking 可以禁用偏向锁。
  • 在开启偏向锁的状态时,如果调用了对象的 hashcode() 方法,则偏向状态将会失效。因为 hash 码会占用偏向锁中存储 thread 的位置。(轻量级锁会在锁记录中记录 hashcode ,重量级锁会在 Monitor 中记录 hashcode)
  • 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的锁对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID。当撤销偏向锁阈值超过 20 次后,jvm 会觉得是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程;当撤销偏向锁阈值超过 40 次后,jvm 会觉得确实偏向错了,于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

1.5 wait & notify

obj.wait() // 让进入 object 监视器的线程到 waitSet 等待obj.wait(long n) // 有时限的等待, 到 n 毫秒后结束等待,或是被 notifyobj.notify() // 在 object 上正在 waitSet 等待的线程中挑一个唤醒obj.notifyAll() // 让 object 上正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁,才能调用这几个方法

我们先聊一聊sleep(long n) wait(long n) 的区别是什么?

  1. sleep Thread 方法,而 wait Object 的方法
  2. sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用。
  3. sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁。
  4. 它们状态都是 TIMED_WAITING。

当有多个线程处于 wait 状态时,使用 notify() 是随机唤醒其中的一个,无法精准地唤醒我们想要的线程,此时需要使用 notifyAll() 来唤醒所有的线程。但如果使用 notifyAll() 唤醒所有线程,需要使用 while() 语句来配合使用:

synchronized(lock) {     while(条件不成立) {  // 条件成立时退出循环,否则每次被唤醒都要重新进入wait  lock.wait();     }     // 干活}//另一个线程synchronized(lock) {     lock.notifyAll();}

【同步模式之保护性暂停】

即 Guarded Suspension,用于一个线程等待另一个线程的执行结果。

GuardedObject 的代码可如下:

class GuardedObject{    // 结果    private Object result;    // 获取结果    public Object getResult() { synchronized (this) {     // 没有结果,就进入等待模式     while (result== null) {  try {      this.wait();  } catch (InterruptedException e) {      e.printStackTrace();  }     }     return result; }    }    // 设置结果    public void setResult(Object result) { synchronized (this) {     // 给结果成员变量赋值     this.result=result;     // 唤醒等待获取结果的线程     this.notifyAll(); }    }}

【异步模式之生产者与消费者】

与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应。消费队列可以用来平衡生产和消费的线程资源。生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据。JDK 中各种阻塞队列,采用的就是这种模式。

消息队列带代码示例:

// 消息队列类,java 线程之间通信class MessageQueue {    private static final Logger log = LoggerFactory.getLogger(MessageQueue.class);    // 消息的队列集合    private LinkedList queue = new LinkedList();    // 队列容量    private int capacity;    public MessageQueue(int capacity) { this.capacity = capacity;    }    // 获取消息    public Message take() { synchronized (queue) {     // 检查队列是否为空     while (queue.isEmpty()) {  log.debug("没货了, wait");  try {      queue.wait();  } catch (InterruptedException e) {      e.printStackTrace();  }     }     // 从队列的头部获取消息返回     Message message = queue.removeFirst();     log.debug("已消费消息{}",message);     queue.notifyAll();     return message; }    }    // 存入消息    public void put(Message message) { synchronized (queue) {     // 检查队列是否已满     while (queue.size() == capacity) {  log.debug("库存已达上限, wait");  try {      queue.wait();  } catch (InterruptedException e) {      e.printStackTrace();  }     }     queue.addLast(message);     log.debug("已生产消息{}", message);     queue.notifyAll(); }    }}

1.6 Park & Unpark

// 暂停当前线程LockSupport.park(); // 恢复某个线程的运行LockSupport.unpark(暂停线程对象)

调用 LockSupport.park() 方法的线程处于 WAITING 状态。

Park & Unpark 与 Wait & Notify 的区别?

  • waitnotify notifyAll 必须配合 Object Monitor 一起使用,而 parkunpark 不必。
  • park & unpark 是以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线 程, notifyAll 是唤醒所有等待线程,就不那么精确。
  • park & unpark 可以先 unpark ,而 wait & notify 不能先 notify。 park & unpark unpark,后 park 时,线程依然可以继续运行(相当于先喝解药、后喝毒药,依然能救活)。

1.7 活跃性

【死锁】

有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁。

例如:t1 线程拥有 A对象 锁,t2 线程拥有 B对象 锁。接下来 t1 线程想获取 B对象 的锁,t2 线程想获取 A对象 的锁,就会导致两个线程都无法获取想要的锁,也都无法继续向下运行,就会导致死锁。

检测死锁可以使用 jconsole工具(直接在左下角搜索);或者使用 jps 定位进程 id ,再用 jstack 定位死锁(在命令行中输入)。

【活锁】

活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,这种现象成为活锁。

【饥饿】

一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,这种现象成为饥饿。

1.8 ReentrantLock(可重入锁)

ReentrantLock 的基本语法: 

// 获取锁reentrantLock对象.lock();try {    // 临界区} finally {    // 释放锁    reentrantLock.unlock();}

相对于 synchronized ,ReentrantLock 具备以下特点:

  • 可中断(指别的线程可以破环你的 BLOCKING 状态,而不是指自己中断阻塞状态)
  • 可以设置超时时间(即设置处于 BLOCKING 的时间)
  • 可以设置为公平锁(先到先得)
  • 支持多个条件变量(即多个 WaitSet)

ReentrantLock   与 synchronized 一样,都支持可重入。接下来,我们依次讲解一下这几个特性:

【可重入】

        可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。 如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。

【可打断】

当调用 ReentrantLock对象.lockInterruptibly() 方法获取锁时,当该锁在 EntryList 中阻塞时,就可以通过其他线程调用 interrupt() 方法打断其 BLOCKING 状态(被动)。这种机制可以避免线程在 EntryList 中 “死等”,从而避免死锁的发生。

ReentrantLock对象.lockInterruptibly()

【锁超时】

此机制可在获取到是否获取到锁的结果后,主动地打断处于 BLOCKING 状态的线程。此方法也包了可打断机制。

// 返回true:表示成功获取锁; 返回false:表示未获取到锁ReentrantLock对象.tryLock(long time,TimeUint uint);// 形参time表示等待的时间,不加则不等待

【公平锁】

公平锁就是在线程进入 EntryList 阻塞队列后,按照先到的顺序依次被唤醒。ReentrantLock 默认是不公平。但我们可以通过设置 ReentrantLock 的构造方法来设计其公平性。

ReentrantLock lock = new ReentrantLock(true);// true表示公平, false表示不公平

【条件变量】

synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待。ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比 synchronized 是那些不满足条件的线程都在一间休息室等消息,而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒。

锁对象.newCondition().await(long time,TimeUint uint);// 进入此休息室等待锁对象.newCondition().signal();// 唤醒此休息室的某一个线程锁对象.newCondition().signalAll();// 唤醒此休息室的所有个线程

await 前需要获得锁,await 执行后,会释放锁,进入 conditionObject 等待。await 的线程被唤醒(或打断、或超时)取重新竞争 lock 锁,竞争 lock 锁成功后,从 await 后继续执行

1.9 经典面试题

至此,我们可以来一道超级经典的面试题:交替输出 abc,即 要求输出 abcabcabcabcabc 该如何实现?(其中线程1 输出 a 5 次,线程2 输出b 5 次,线程3 输出 c 5 次)。

分别通过 synchronized & wait & notify 、 ReentrantLock & lock & unlock 和 LockSupport & park & unpark 三种方法来解决。

详情见:线程共享经典面试题之交替输出(三种解法)https://blog.csdn.net/weixin_52850476/article/details/123858058?spm=1001.2014.3001.5502

【同步与互斥】

互斥:使用 synchronized Lock 达到共享资源互斥效果,即临界区的代码不会因为上下文的切换而产生指令的交错,保证临界区代码的原子性。

同步:使用 wait/notify Lock 的条件变量来达到线程间通信效果,即当条件不满足时让线程进入等待,当条件满足时恢复运行。