并发编程(学习笔记-共享模型之管程)-part3

Posted LL.LEBRON

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程(学习笔记-共享模型之管程)-part3相关的知识,希望对你有一定的参考价值。

并发编程-共享模型之管程-3

本文章视频指路👉黑马程序员-并发编程

1.共享带来的问题

1-1 临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

例如,下面代码中的临界区:

static int counter = 0;
 
static void increment() 
// 临界区 
{   
    counter++; 
}
 
static void decrement() 
// 临界区 
{ 
    counter--; 
}

1-2 竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

2.synchronized 解决方案

2-1 解决方案

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

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

该节使用阻塞式的解决方案:synchronized,来解决上述问题,即俗称的**【对象锁】,它采用互斥**的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住(blocked)。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

注意:

虽然 java 中互斥同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

2-2 synchronized 的使用

语法

synchronized(对象) {
	//临界区
}

解决

static int counter = 0; 
//创建一个公共对象,作为对象锁的对象
static final Object room = new Object();
 
public static void main(String[] args) throws InterruptedException {    
	Thread t1 = new Thread(() -> {        
    for (int i = 0; i < 5000; i++) {            
        synchronized (room) {     
        counter++;            
       	 }       
 	   }    
    }, "t1");
 
    Thread t2 = new Thread(() -> {       
        for (int i = 0; i < 5000; i++) {         
            synchronized (room) {            
            counter--;          
            }    
        } 
    }, "t2");
 
    t1.start();    
    t2.start(); 
    t1.join();   
    t2.join();    
    log.debug("{}",counter); 
}

2-3 方法上的synchronized

  • 加在成员方法上,锁是当前实例对象

    public class Demo {
    	//在方法上加上synchronized关键字
    	public synchronized void test() {
    	
    	}
    	//等价于
    	public void test() {
    		synchronized(this) {
    		
    		}
    	}
    }
    
  • 加在静态方法上,锁是当前类的Class对象

    public class Demo {
    	//在静态方法上加上synchronized关键字
    	public synchronized static void test() {
    	
    	}
    	//等价于
    	public void test() {
    		synchronized(Demo.class) {
    		
    		}
    	}
    }
    

3.变量的线程安全分析

3-1 成员变量和静态变量是否线程安全?

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

3-2 局部变量是否线程安全?

  • 局部变量是线程安全的

  • 局部变量引用的对象则未必 (要看该对象是否被共享且被执行了读写操作)

    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全
  • 局部变量是线程安全的—每个方法都在对应线程的栈中创建栈帧,不会被其他线程共享

  • 如果调用的对象被共享,且执行了读写操作,则线程不安全

  • 如果是局部变量,则会在堆中创建对应的对象,不会存在线程安全问题

3-3 常见线程安全类

  • String
  • Integer
  • StringBffer
  • Random
  • Vector (List的线程安全实现类)
  • Hashtable (Hash的线程安全实现类)
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

例如:

Hashtable table = new Hashtable();

new Thread(()->{
    table.put("key", "value1");
}).start();
new Thread(()->{
    table.put("key", "value2");
}).start();
  • 它们的每个方法原子的(都被加上了synchronized
  • 但注意它们多个方法的组合不是原子的,所以可能会出现线程安全问题

线程安全类方法的组合

分析下面代码是否线程安全?

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

可以多个方法的组合不是原子的,所以可能会出现线程安全问题

不可变类线程安全性

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

有同学或许有疑问,String 有 replace,substring 等方法可以改变值啊,那么这些方法又是如何保证线程安全的呢?

这是因为这些方法的返回值都创建了一个新的对象,而不是直接改变String、Integer对象本身。

3-4 实例分析

例1:

public class MyServlet extends HttpServlet {
    // 是否安全?no
    Map<String,Object> map = new HashMap<>();
    // 是否安全?yes
    String S1 = "...";
    // 是否安全?yes
    final String S2 = "...";
    // 是否安全?no
    Date D1 = new Date();
    // 是否安全?no,引用不变,但是里面的年月日是可变的
    final Date D2 = new Date();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        //使用上述变量
    }
}

例2:

public class MyServlet extends HttpServlet {
    // 是否安全?no,共享的
    private UserService userService = new UserServiceImpl();

    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        userService.update(...);
    }
}
public class UserServiceImpl implements UserService {
    // 记录调用次数
    private int count = 0;//这里多个线程调用可能出问题

    public void update() {
        // ...
        count++;
    }
}

例3:

@Aspect
@Component
public class MyAspect {//单例,成员变量会共享
    // 是否安全?no
    private long start = 0L;

    @Before("execution(* *(..))")
    public void before() {
        start = System.nanoTime();
    }

    @After("execution(* *(..))")
    public void after() {
        long end = System.nanoTime();
        System.out.println("cost time:" + (end-start));
    }
}

4.Monitor

4-1 Java对象头

这里以32位虚拟机为例:

普通对象:

数组对象:

其中Mark Word 结构为:

64 位虚拟机 Mark Word:

4-2 原理之Monitor

Monitor被翻译为监视器管程

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针

Monitor 结构如下:

  • 刚开始 Monitor 中 Owner 为 null
  • 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
  • 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList (进入阻塞状态,BLOCKED )
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析

注意:

  • synchronized 必须是进入同一个对象的 monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

5.Synchronized原理进阶

5-1 轻量级锁

  • 轻量级锁使用场景:当一个对象被多个线程所访问,但访问的时间是错开的(不存在竞争),此时就可以使用轻量级锁来优化。
  • 轻量级锁对使用者是透明的,即语法仍然是 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(不再一开始就使用Monitor)

  • 让锁记录中的Object reference指向锁对象(Object),并尝试用cas去替换Object中的mark word,将此mark word放入lock record中保存

  • 如果cas替换成功,则将Object的对象头替换为锁记录的地址状态 00(轻量级锁状态),并由该线程给对象加锁

  • 如果 cas 失败,有两种情况:

    • 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程

    • 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数

  • 当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

  • 当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头

    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

5-2 锁膨胀

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

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁

  • 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程

    • 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
    • 然后自己进入 Monitor 的 EntryList BLOCKED

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

5-3 自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
  • Java 7 之后不能控制是否开启自旋功能

5-4 偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有

偏向状态

  • Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
  • Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
  • Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后两位为状态(00)
  • Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,后两位为状态(10)

  • 如果开启了偏向锁(默认开启),在创建对象时,对象的Mark Word后三位应该是101
  • 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态
  • 如果没有开启偏向锁,对象的Mark Word后三位应该是001

撤销偏向

以下几种情况会使对象的偏向锁失效

  • 调用对象的hashCode方法
  • 多个线程使用该对象
  • 调用了wait/notify方法(调用wait方法会导致锁膨胀而使用重量级锁

批量重偏向

  • 如果对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向T1的对象仍有机会重新偏向T2。重偏向会重置Thread ID
  • 当撤销超过20次后(超过阈值),JVM会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程

批量撤销

当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

6.Wait/Notify

6-1 原理

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notifynotifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争

这里附《Java并发编程的艺术》里的一张图:

6-2 API介绍

  • wait() 让进入 object 监视器的线程到 waitSet 等待
  • notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
  • notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒
  • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到 notify
  • 为止 wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify

注意:只有当对象被锁以后,才能调用wait和notify方法

例子:

@Slf4j
public class Test6 {
    final static Object lock = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lock) {
                log.debug("执行...");
                try {
                    lock.wait();//让线程在lock上一直等待下去
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug("其他代码...");
            }
        }, "t1").start();

        new Thread(() -> {
            synchronized (lock) {
                log.debug("执行...");
                try {
                    lock.wait();//让线程在lock上一直等待下去
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug("其他代码...");
            }
        }, "t2").start();
        Sleeper.sleep(2);
        log.debug("唤醒 lock 上的线程");
        synchronized (lock) {
            lock.notify(); //唤醒 lock 上的一个线程
            //lock.notifyAll(); //唤醒 lock 上所有等待线程
        }
    }
}
//输出
20:36:08 DEBUG [t1] (Test6.java:13) - 执行...
20:36:08 DEBUG [t2] (Test6.java:25) - 执行...
20:36:10 DEBUG [main] (Test6.java:35) - 唤醒 lock 上的线程
20:36:10 DEBUG [t1] (Test6.java:19) - 其他代码...

6-3 wait/notify 的正确使用

开始之前先看看 sleep(long n)wait(long n) 的区别 :

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

以下部分建议参考:《Java并发编程的艺术》读后笔记-part4

等待方遵循如下原则:

  1. 获取对象的锁
  2. 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
  3. 条件满足则执行对应的逻辑

对应伪代码:

synchronized(对象){
    while(条件不满足){
        对象.wait();
    }
    对应的处理逻辑
}

通知方遵循如下原则:

  1. 获得对象的锁
  2. 改变条件
  3. 通知所有等待在对象上的线程

对应伪代码:

synchronized(对象){
    改变条件
    对象.notifyAll();
}

7.Park/Unpark

7-1 基本使用

park/unpark都是 LockSupport 类中的方法

//暂停线程运行
LockSupport.park;

//恢复线程运行
LockSupport.unpark(thread);

先 park 再 unpark:

@Slf4j
public class Test24 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            log.debug("start...");
            Sleeper.sleep(1);
            log.debug("park...");
            //暂停线程运行
            LockSupport.park();
            log.debug("resume...");
        }, "t1");
        t1.start();
        Sleeper.sleep(2);
        log.debug("unpark...");
        //恢复线程运行
        LockSupport.unpark(t1);
    }
}
13:11:08 DEBUG [t1] (Test24.java:12) - start...
13:11:09 DEBUG [t1] (Test24.java:14) - park...
13:以上是关于并发编程(学习笔记-共享模型之管程)-part3的主要内容,如果未能解决你的问题,请参考以下文章

3.java多线程学习笔记之共享模型之管程

3.多线程学习笔记之共享模型之管程

3.多线程学习笔记之共享模型之管程

如何避免可怕的中年危机?成功入职阿里

大厂面试必备:docker退出容器之后会不会保存

Java高级工程师进阶学习:kafka应用场景