Skip to content

并发理论基础

并发编程核心

  • 分工 , 如何高效的拆解任务, 并分配给线程
  • 同步 , 线程之间如何协作, 一个线程执行完了任务, 如何通知执行后续任务的线程开工

线程协作问题: 当某个条件不满足时, 线程需要等待; 当某个条件满足时, 线程需要被唤醒执行

  • 互斥 , 即线程安全, 保证同一时刻只允许一个线程去访问共享资源

1744805438185

可见性, 原子性, 有序性

缓存导致的可见性问题

CPU执行速度>>内存读写速度>>I/O设备读写速度

因此程序整体的性能取决于最慢的操作--I/O设备

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 增加了缓存,以均衡与内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

一个线程对共享变量的修改, 另外一个线程能够立刻看到, 我们称之为可见性.

如图, 多核CPU导致线程A对变量V的操作对线程B不可见

1744805447348

线程切换带来的原子性问题

一个或多个操作在CPU执行的过程中不被中断的特性称为原子性

CPU能保证的原子操作是CPU指令级别的, 而不是高级语言操作符

如图, 线程切换, 使得线程AB都执行了count+=1语句, 最后得到的结果还是1

1744805454028

编译优化带来的有序性问题

例子: 双重检查创建单例对象

Java
public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

1744805460937

解决方法: 使用volatile关键字修饰instance禁止重排序

Java内存模型

解决可见性, 有序性==>按需禁用缓存以及编译优化

volatile 禁用缓存以及编译优化

Happens-Before规则

前面一个操作的结果对于后续操作是可见的

  1. 程序的顺序性规则 这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作

  2. volatile 变量规则 对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。

  3. 传递性 如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。

    示例:如果A线程调用writer方法, B线程执行reader方法, 那x会是多少? 规则1使得第7行happens-before第8行 规则2使得第8行happens-before第11行 规则3使得第7行happens-before第11行, 因此最终拿到的x会是42

1744805467291

Java

// 以下代码来源于【参考1】
class VolatileExample {
  int x = 0;
  volatile boolean v = false;
  public void writer() {
    x = 42;
    v = true;
  }
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}
  1. 管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

  1. 线程 start() 规则

主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

Java

Thread B = new Thread(()->{
  // 主线程调用B.start()之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量var修改
var = 77;
// 主线程启动子线程
B.start();
  1. 线程 join() 规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。

换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。具体可参考下面示例代码。

Java

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

final

final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以可劲儿优化

但要避免"逸出", 在下面例子中,在构造函数里面将 this 赋值给了全局变量 global.obj,这就是“逸出”,线程通过 global.obj 读取 x 是有可能读到 0 的

Java

// 以下代码来源于【参考1】
final int x;
// 错误的构造函数
public FinalFieldExample() { 
  x = 3;
  y = 4;
  // 此处就是讲this逸出,
  global.obj = this;
}

互斥锁: 解决原子性问题

锁模型

1744805479018

synchronized关键字

synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象:

  • 修饰代码块;
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this。
Java

class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}

锁和受保护资源之间的关系

受保护资源和锁之间的关联关系是 N:1 的关系, 即可以用一把锁来保护多个资源, 而不应该对于同一资源使用多把锁

示例: 这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class, 由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

Java

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

1744805487001

保护没有关联关系的多个资源

用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

示例: 此处用两把锁,取款和修改密码是可以并行的

Java

class Account {
  // 锁:保护账户余额
  private final Object balLock
    = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock
    = new Object();
  // 账户密码
  private String password;

  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看余额
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }

  // 更改密码
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密码
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }
}

保护有关联关系的多个资源

示例:

假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元。我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什么呢?线程 1 和线程 2 都会读到账户 B 的余额为 200,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不可能是 200。

Java
class Account {
  private int balance;
  // 转账
  synchronized void transfer(
      Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

1744805497595

改进方案: 使用Account.class作锁, 能够覆盖所有受保护资源

Java
class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

总结: 如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。

“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。

死锁问题

账户转账问题中, 如果用Account.class作为锁的话, 所有账户的转账工作都是串行的, 性能太差

优化如下: 使用两把锁, 先锁this, 再锁target

Java
class Account {
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 锁定转出账户
    synchronized(this) {            
      // 锁定转入账户
      synchronized(target) {         
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

使用细粒度锁可以提高并行度,是性能优化的一个重要手段,

但, 使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

1744805505646

死锁: 一组互相竞争资源的线程因互相等待, 导致"永久"阻塞的现象

并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。

发生死锁的条件:

1.互斥 ,共享资源 X 和 Y 只能被一个线程占用;

2.占有且等待 ,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;

3.不可抢占 ,其他线程不能强行抢占线程 T1 占有的资源;

4.循环等待 ,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。

从破坏条件入手, 来规避死锁:

1.互斥==>无法破坏

2.占用且等待==>可以一次性申请所有资源, 则不存在等待问题

1744805513306

Java

class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申请所有资源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr应该为单例
  private Allocator actr;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    // 一次性申请转出账户和转入账户,直到成功
    while(!actr.apply(this, target))

    try{
      // 锁定转出账户
      synchronized(this){            
        // 锁定转入账户
        synchronized(target){         
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

3.不可抢占==>占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源

使用lock

4.循环等待==> 可以靠按序申请资源来预防 。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

Java

class Account {
  private int id;
  private int balance;
  // 转账
  void transfer(Account target, int amt){
    Account left = this
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 锁定序号小的账户
    synchronized(left){
      // 锁定序号大的账户
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

用"等待-通知"机制优化循环等待

上例中的代码问题, 如果 apply() 操作耗时长,或者并发冲突量大的时候, 可能要循环上万次才能获取到锁,太消耗 CPU 了。

Java
// 一次性申请转出账户和转入账户,直到成功
while(!actr.apply(this, target))

优化方案:

如果线程要求的条件(转出账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;当线程要求的条件(转出账本和转入账本同在文件架上)满足后,通知等待的线程重新执行。其中,使用线程阻塞的方式就能避免循环等待消耗 CPU 的问题。

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

注意:

wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException。

Java
class Allocator {
  private List<Object> als;
  // 一次性申请所有资源
  synchronized void apply(
    Object from, Object to){
    // 经典写法
    while(als.contains(from) ||
         als.contains(to)){
      try{
        wait();
      }catch(Exception e){
      }   
    } 
    als.add(from);
    als.add(to);  
  }
  // 归还资源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
    notifyAll();
  }
}

尽量使用 notifyAll()

notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程

使用 notify() 的风险在于可能导致某些线程永远不会被通知到。举例:

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

安全性, 活跃性以及性能问题

安全性问题

理论上线程安全的程序,就要避免出现原子性问题、可见性问题和有序性问题。

多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race).

**竞态条件,指的是程序的执行结果依赖线程执行的顺序; **在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:

Java

if (状态变量 满足 执行条件) {
  执行操作
}

那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢? 其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。

活跃性问题

活跃性问题,指的是某个操作无法执行下去 。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。

活锁:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁” 类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。这种情况,基本上谦让几次就解决了,因为人会交流啊。可是如果这种情况发生在编程世界了,就有可能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。

解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。

饥饿: 指的是线程因无法访问所需资源而无法执行下去的情况。 “不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。

解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源(使用公平锁),三就是避免持有锁的线程长时间执行。

性能问题

“锁”的过度使用可能导致串行化的范围过大

第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。 在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……

第二,减少锁持有的时间。 互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

  • 吞吐量 :指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  • 延迟 :指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  • 并发量 :指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。 并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。

管程:并发编程的万能钥匙

管程(Monitor), 指的是管理共享变量以及对共享变量的操作过程, 让他们支持并发;

翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。

管程如何解决互斥与同步问题

互斥: 将共享变量及其对共享变量的操作统一封装起来。

1744805527493

同步: MESA模型

1744805533995

下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口

Java

public class BlockedQueue<T>{
  final Lock lock =
    new ReentrantLock();
  // 条件变量:队列不满  
  final Condition notFull =
    lock.newCondition();
  // 条件变量:队列不空  
  final Condition notEmpty =
    lock.newCondition();

  // 入队
  void enq(T x) {
    lock.lock();
    try {
      while (队列已满){
        // 等待队列不满 
        notFull.await();
      }  
      // 省略入队操作...
      //入队后,通知可出队
      notEmpty.signal();
    }finally {
      lock.unlock();
    }
  }
  // 出队
  void deq(){
    lock.lock();
    try {
      while (队列已空){
        // 等待队列不空
        notEmpty.await();
      }
      // 省略出队操作...
      //出队后,通知可入队
      notFull.signal();
    }finally {
      lock.unlock();
    }  
  }
}

wait() 的正确姿势

对于 MESA 管程来说,有一个编程范式,就是需要在一个 while 循环里面调用 wait()。这个是 MESA 管程特有的。

Java
while(条件不满足) {
  wait();
}

Hasen 模型、Hoare 模型和 MESA 模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程 T2 的操作使线程 T1 等待的条件满足时,T1 和 T2 究竟谁可以执行呢?

  • Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
  • Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
  • MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。

notify() 何时可以使用

除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?

需要满足以下三个条件:

  • 所有等待线程拥有相同的等待条件;
  • 所有等待线程被唤醒后,执行相同的操作;
  • 只需要唤醒一个线程。

Java线程: Java线程的生命周期

通用的线程生命周期

1744805541511

1.初始状态 ,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。

2.可运行状态 ,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。

3.当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了 运行状态

4.运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到 休眠状态 ,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。

5.线程执行完或者出现异常就会进入 终止状态 ,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

Java中线程的生命周期

Java 语言中线程共有六种状态,分别是:NEW(初始化状态)RUNNABLE(可运行 / 运行状态)BLOCKED(阻塞状态)WAITING(无时限等待)TIMED_WAITING(有时限等待)TERMINATED(终止状态)

但其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。

1744805547493

  1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。

  1. RUNNABLE 与 WAITING 的状态转换

总体来说,有三种场景会触发这种转换。

第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。

第二种场景,调用无参数的 Thread.join() 方法。

其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。

第三种场景,调用 LockSupport.park() 方法。

其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。

  1. RUNNABLE 与 TIMED_WAITING 的状态转换

有五种场景会触发这种转换:

  • 调用带超时参数的 Thread.sleep(long millis) 方法;
  • 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
  • 调用带超时参数的 Thread.join(long millis) 方法;
  • 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  • 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。

这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。

  1. 从 NEW 到 RUNNABLE 状态

NEW 状态的线程,不会被操作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了,

  1. 从 RUNNABLE 到 TERMINATED 状态

线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止

补充: 强行中断run()方法的执行

建议调用 interrupt() 方法。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。

当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。

当线程 A 处于 RUNNABLE 状态时,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;而阻塞在 java.nio.channels.Selector 上时,如果其他线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。

上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。

注意: interrupt不会结束线程的运行,在抛出InterruptedException后会清除中断标志(代表可以接收下一个中断信号了

Java线程: 创建多少线程才是合适的?

为什么要使用多线程

提升性能==>降低延迟, 提高吞吐量

  • 延迟: 指的是发出请求到收到响应的这段时间; 延迟越短, 意味着程序执行的越快, 性能也就越好
  • 吞吐量: 指的是单位时间内能接收的请求数量; 吞吐量越大, 意味着程序能处理的请求越多, 性能也就越好

同等条件下延迟越短, 吞吐量越大, 但由于他们属于不同的维度(一个是时间维度, 一个是空间维度), 并不能相互转换

多线程的应用场景

"降低延迟, 提高吞吐量"方法:

  1. 优化算法
  2. 将硬件的性能发挥到极致==>在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率==>多线程能够提高CPU以及I/O的利用率

创建多少线程合适?

CPU密集型 : 多线程本质上是提升多核 CPU 的利用率, ** 理论上“线程的数量 =CPU 核数”就是最合适的** 。不过 在工程上,线程的数量一般会设置为“CPU 核数 +1” ,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。

I/O密集型: 最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]

Java线程: 为什么局部变量是线程安全的?

因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。

1744805556925

线程封闭

概念: 仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。

采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。

如何用面对对象思想写好并发程序

封装共享变量

将共享变量作为对象属性封装在内部, 对所有公共方法制定并发访问策略

且, 对于这些不会发生变化的共享变量,建议用 final 关键字来修饰

示例: Counter类就是一个线程安全的类

Java
public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}

识别共享变量间的约束条件

在设计阶段, 一定要识别出所有共享变量之间的约束条件, 如果约束条件识别不足, 很可能导致制定的并发访问策略南辕北辙

示例: 原子类不能保证不能保证库存下限要小于库存上限这个约束条件

Java

public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    // 检查参数合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    // 检查参数合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他业务代码
}

制定并发访问策略

  • 避免共享: 使用线程本地存储以及为每个任务分配独立的线程
  • 不变模式: java中应用较少, 但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  • 管程及其他同步工具: 使用 Java 并发包提供的读写锁、并发容器等同步工具

宏观原则:

  1. 优先使用成熟的工具类 :Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
  2. 迫不得已时才使用低级的同步原语 :低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
  3. 避免过早优化 :安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

补充答疑

Integer 和 String 和Boolean类型的对象不适合做锁

锁,应是私有的、不可变的、不可重用的。

Integer会缓存-128~127这个范围内的数值,String对象同样会缓存字符串常量到字符串常量池,可供重复使用,所以不能用来用作锁对象,此外还有Boolean可能被JVM重用

示例: 下面代码线程不安全

Java

class Account {
  // 账户余额  
  private Integer balance;
  // 账户密码
  private String password;
  // 取款
  void withdraw(Integer amt) {
    synchronized(balance) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 更改密码
  void updatePassword(String pw){
    synchronized(password) {
      this.password = pw;
    }
  } 
}

规范的锁示例:

Java

// 普通对象锁
private final Object 
  lock = new Object();
// 静态对象锁
private static final Object
  lock = new Object();

最佳线程 =2 * CPU 的核数 + 1 ?

从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O 耗时 / CPU 耗时”不太容易确定的系统来说,却是一个很好到初始值。

实际工作中面临的系统, “I/O 耗时 / CPU 耗时”往往都大于 1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。