并发解决方案之共享模型

266 阅读5分钟

知识概览

  • 多个线程访问共享资源问题
  • synchronized
  • 线程安全分析
  • Monitor
  • wait/notify
  • 线程状态转换
  • 活跃性
  • Lock

共享问题

public class Test {
    static int i = 0 ;

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                i++;
            }
        });

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                i--;
            }
        });

        thread.start();
        thread1.start();
        thread.join();
        thread1.join();
        System.out.println(i);
    }
}

分时系统造成的线程安全问题,产生指令之间的交错(线程方法非原子性),根本原因是上下文的切换。

自增

getstatic i
iconst_1
iadd
putstatic i

自减

getstatic i
iconst_1
isub 
putstatic

两个线程完成静态变量的操作要在主内存和工作内存中进行数据交换。 image.png

多线程下问题分析: 线程A时间片用完以后,线程进入就绪状态(此时并没有将计算结果写入主内存),另一个线程B由就绪状态转为运行状态,发生线程上下文切换。另一个线程运行完毕(写入主内存),结束运行,cpu调度线程A ,线程A将结果写入主内存,发生指令交错。

临界区和竞态条件

一段代码如果存在对共享资源的多线程的读写操作,这段代码成为临界区。

static int cnt;

static void inc(){
    //临界区
    cnt ++;
}

static void dec() {
    // 临界区
    cnt --;
}

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

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

synchronized

对象锁,要给同一个对象加锁,保证拿到同一个锁对象。要对临界区加锁,所有线程都要上锁。这样才能保证临界区代码的原子性。

变量的线程安全分析

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

  • 若他们没有共享,则线程安全。堆内存,线程共享的
  • 若共享
    • 只有读操作,线程安全
    • 有读写操作,则这段代码为临界区,需要考虑线程安全。

局部变量线程安全吗?

  • 是线程安全的
  • 局部变量引用的对象未必。 对象存在堆里
    • 对象没有逃离方法的作用范围,线程安全
    • 对象逃逸,需要考虑线程安全问题。

对 i 不需要加保护。每个线程调用test方法时,局部变量i会在每个线程的栈帧内存中被创建多份,不存在共享

public static void test() {
    int i = 10;
    i ++;
}

常见线程安全类

  • String
  • 包装类
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • juc 包

每个方法是原子性的,但是多个方法的组合不是原子的!

对于组合操作来说,单方法线程安全,组合在一起就是线程不安全的了。

void addIfNotExitst(Vectot v, Object o) {
       if (!v.contains(o)) v.add(o); 
}

假设两个线程执行该方法,线程1执行了[contains]方法,发生了上下文切换,线程2拿到锁此时执行[contains]方法,两个都是线程执行的结果false,集合里就会出现俩个相同对象

不可变类是线程安全的

String、Integer 等都是不可变类,其内部状态不能被改变,因此所有的方法都是线程安全的。

image.png

Mointior

被翻译为监视器或管程

每个Java对象都可以关联一个Monitor对象,如果使用Synchronize给对象上锁,该对象头的Markword中就被设置指向Monitor对象的指针。任意 Java 对象上都有三个容器用来实现管程,分别是 EntryList、WaitSet 和 Owner。EntryList 是管程的入口队列(BLOCKED 阻塞状态,RNNABLE -> BLOCKED 上下文切换),WaitSet 是条件等待队列,Owner 表示当Monitor的所有者。

image.png

Syn 必须锁同一个对象。不用syn锁不会走monitor

wait与notify

等待通知机制 : 线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足 时,通知等待的线程,重新获取互斥锁。

工作原理

image.png

  • entrylist 和 互斥锁是一对一的关系,同一时刻,只保证有一个线程能够进入临界区。未进入临界区的线程状态为Block状态
  • 进入互斥锁的线程发现条件不满足,调用wait方法,进入 waitset 等待队列。此时线程状态为waitting状态。线程进入此队列的同时,会释放持有的互斥锁。entrylist中的线程就会有机会竞争锁,并进入临界区。
  • waiting 和 block 都不会占用cpu。

如何唤醒?Java提供了两个API notiy() 和 notifyAll() ,这两个方法会唤醒waitSet等待队列中的线程。 但要注意到的是,notify 只是在通知的这一时刻条件满足咯,线程拿到锁是否满足还不可知。

API 介绍

  • obj.wait 让进入 obj 监视器的线程进入 waitSet 队列等待。 0 无限制、
  • obj.notify 在 waitSet 中 挑一个唤醒
  • obj.notifyAll 唤醒 waitSet 中全部的线程。 前提必须拿到对象锁,才能使用这几个API 在没有获得锁对象,执行wait方法,会有IllegalMonitorStateException异常。

wait 和 notify 正确姿势

先来看下 sleep 和 wait 区别 (重点也是面试常考)

  • sleep 是 Thread 的方法,wait 是 Object 方法
  • sleep 不要强制和 syn 配合使用,wait 一定要获取锁才行。
  • sleep 睡眠不会释放锁,wait等待时会释放锁。
  • 共同点
    • 线程状态都是timedWaiting

尽量使用 notifyAll

假设我们有资源 A、B、C、D,线程 1 申请到了 AB,线程 2 申请到了 CD,此时线程 3 申请 AB,会进入等待队列(AB 分配给线程 1,线程 3 要求的条件不满足),线程 4 申请 CD 也 会进入等待队列。我们再假设之后线程 1 归还了资源 AB,如果使用 notify() 来通知等待队列 中的线程,有可能被通知的是线程 4,但线程 4 申请的是 CD,所以此时线程 4 还是会继续等 待,而真正该唤醒的线程 3 就再也没有机会被唤醒了。 使用notifyAll , 让满足条件的线程来执行。

奉上 wait-notify 套路

wait 线程

synchronized (lock){
    while(false) {
        lock.wait();
    }
    // 干活。
}

唤醒线程

synchronized (lock){
    while(false) {
        lock.notify();
    }
}