并发编程大揭秘:锁中锁,线程生命周期和程序员的终极武器

并发编程BUG的源头

可见性、原子性、有序性。

为解决CPU、内存、I/O设备之间的速度差异:

  1. 计算机体系结构采用了 CPU缓存技术 (即多核+缓存带来了可见性问题)
  2. 操作系统采用了 多线程分时复用CPU (即线程切换带来了原子性问题)
  3. 编译程序采用了优化指令执行次序 (即指令重排带来了有序性问题)
  4. 总结:在一个技术解决一个问题的时候,必然会带来其他的问题。

Java内存模型(规范)

可以理解为 提供了按需禁用缓存和编译优化,提供了一些方法:

  1. volatilesynchronizedfinal(禁止final变量的指令重排)
  2. Happens-Before规则(可见性)

事件A Happens-Before 事件B,无论A、B是否在一个线程,事件A的操作对事件B都可见

  1. 程序的顺序规则(同一个线程中,上一个操作对下一个操作可见)
  2. volatile变量规则(对volatile变量的写操作对于后续的 volatile读操作可见)
  3. 传递性(A Happens-Before B,B Happens-Before C,可以推出A Happens-Before C)
  4. 管程中锁的规则(同一把锁,线程A解锁,线程B加锁,线程A的操作对线程B可见)
  5. 线程start()规则(线程A中,start()线程B,线程A的操作对线程B可见)
  6. 线程join规则(线程A中,join()线程B,线程B的操作对线程A可见)

互斥锁解决原子性问题

Java互斥锁锁的实现:synchronized

以解决 i++ 的原子性为例,定义一个类,提供get,add方法,保证 i++的的并发问题

用同一把锁 锁住get和add方法即可,即同一时刻只允许一个线程访问变量 i ,保证了原子性。

由于 Happens-Before 中的管程中锁的规则,也保证了 i 的可见性。

java 复制代码
long i = 0L;
​
synchronized long get(){
  return this.i;
}
​
synchronized void add(){
  i++;
}

互斥锁:如何用一把锁保护多个资源

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

比如:Account类中有 余额和密码两个资源,其互不影响,可以用两把锁分别锁住。

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

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

比如:Account类中的 余额 向他人转账,资源有自己的余额、他人的余额,我们需要同时锁住,保证其原子性,就不能用this锁,我们可以用Account.class对象来锁。

java 复制代码
public class Account {
  
  private int balance;
  
  public void transfer(Account target, int amt){
    synchronized (Account.class){
      if (this.balance >= amt){
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}

一不小心就死锁了

分析:上述Account类的 transfer() A to B时不能 C to D,转账完全串行化,效率太低。

解决:可以利用细粒度锁,用两把锁分别锁住 this 和 target,只有同时拿到才能转账,这样就可以实现A to B时可以 C to D,代码如下:

java 复制代码
class Account{
  private int balance;
  
  public void transfer(Account target , int amt){
    
    synchronized (this){
      
      synchronized (target) {
        
        if (this.balance >= amt) {
          
          this.balance -= amt;
          
          target.balance += amt;
        }
      
      }
    
    }
  
  }
}

问题:随之而来的问题就是死锁,A to B 和 B to A时,两个线程一个获取了A锁,一个获取了B锁,死锁。

怎么预防死锁问题,满足死锁的四个条件破环一个即可:

  1. 互斥(使用锁就是为了互斥,无法破坏)
  2. 占有且等待(利用自定义Allocator类一次性申请获取两个资源,再锁定这两个资源)
  3. 不可抢占(核心就是能过主动释放占有的资源,synchronized无法实现,需要并发包)
  4. 循环等待(给资源排序,让需申请资源,如A to B和B to A时,都先锁A,再锁B)

破坏占有且等待

java 复制代码
public class Allocator {
  private final static List<Object> als = new ArrayList<>();
  //申请资源
  public synchronized static boolean apply(Object from,Object to){
    if (als.contains(from) || als.contains(to)){
      return false;
    }else {
      als.add(from);
      als.add(to);
    }
    return true;
  }
  //释放资源
  public synchronized static void free(Object from,Object to){
    als.remove(from);
    als.remove(to);
  }
  class Account{
    private int balance;
    public void transfer(Account target , int amt){
      while (!Allocator.apply(this,target));
      try {
        synchronized (this){
          synchronized (target) {
            if (this.balance >= amt) {
              this.balance -= amt;
              target.balance += amt;
            }
          }
        }
      } finally {
        Allocator.free(this,target);
      }
  }
}

破坏循环等待

java 复制代码
public class Account {
  private int id;
  private int balance;
  public 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;
        }
      }
    }
  }
}

利用wait/notify机制优化循环等待

分析:上述 破坏占有且等待 ****使用一次性申请资源,并在 Account类中 利用while (!Allocator.apply(this,target));循环等待资源,可以继续优化。

问题:在并发量大、apple执行时间过长的的时候,while循环等待太消耗CPU了。

解决:利用wait/notify机制,在申请不到的时候,就等待队列等着,别占用CPU,释放资源的时候,唤醒等待队列的所有线程即可。

java 复制代码
public class Allocator {
​
  private final static List<Object> als = new ArrayList<>();
​
  //申请资源
​
  public synchronized static void apply(Object from,Object to){
​
    while (als.contains(from) || als.contains(to)){
​
      try {
​
        Allocator.class.wait();
​
      } catch (InterruptedException e) {
​
        e.printStackTrace();
​
      }
    
    }
​
    als.add(from);
​
    als.add(to);
​
}
​
//释放资源
​
public synchronized static void free(Object from,Object to){
​
  als.remove(from);
​
  als.remove(to);
​
  Allocator.class.notifyAll();
​
}
​
class Account{
  private int balance;
​
  public void transfer(Account target , int amt){
    Allocator.apply(this,target);
    try {
      synchronized (this){
        synchronized (target) {
          if (this.balance >= amt) {
            this.balance -= amt;
            target.balance += amt;}}}} finally {
      Allocator.free(this,target);
    }
  }
}
}

wait/notify是基于同步机制实现,目的为了实现其可见性。

wait:调用wait()方法时先要获取对象的锁,方法调用之后又会释放对象的锁,进入等待队列。

notify:从等待队列中随机唤醒一个等待线程。

notifyAll:唤醒等待队列的所有线程。

为什么用notifyAll而不用notify?因为可能存在线程永远唤不醒的情况。

安全性、活跃性及性能问题

安全性:

即我们的程序按照我们的期望执行,问题的源头就是前面提到的原子性、可见性、有序性。

我们程序员写的每个程序都要看释放有这三个问题吗,显然太麻烦。我们只需要考虑两个问题:

1、是否存在共享数据并该数据会发生变化,即多线程同时读写同一数据,也叫做数据竞争。

2、程序的执行结构依赖于程序的执行顺序,即程序的执行依赖于某个状态变量,也叫做竞态条件。

活跃性:

即某个操作无法执行下去,原因有三:

死锁,如两个线程竞争对方已获取的资源。

活锁,如两个线程竞争对方已获取的资源时,同时放弃已有资源,又重试竞争,导致死循环下去;

解决:设置一个随机等待时间。

饥饿,即线程无法访问所需资源导致执行不下去,如线程执行优先级不均;

解决:公平分配资源,如公平锁。

性能:

过度使用锁可能会导致串行化的范围过大,出现性能问题,导致无法发挥多线程的优势。解决:

1、 无锁算法和数据结构,如线程本地存储(Thread Local Storage TLS)、写入时复制(Copy-on-write)、乐观锁、原子类(CAS)、Disruptor 一个无锁的内存队列。

2、减少持有锁的时间,如细粒度锁、分段锁、读写锁。

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

管程(Monitor)就是大家常说的监视器,下面是原理图:

Monitor解决并发问题,互斥(只允许一个线程访问)和同步(线程间的通信)问题:

将需要互斥的资源和操作封装到一个Monitor对象中,该对象只允许一个线程同时访问 ,即解决了互斥问题

感觉和wait/notify 机制一样,只不过有多个条件变量,即解决了同步问题

Java中内置的管程方案(synchronized)仅支持一个条件变量,Java 并发包下的Lock支持多各条件变量。

Java线程的生命周期

通用(OS)的线程生命周期

  1. 初始状态(语言层面被创建)
  2. 可运行状态(可被分配CPU执行)
  3. 运行状态(CPU执行)
  4. 休眠状态(等待某个事件)
  5. 终止状态(线程执行完后异常结束)

Java

对于Java来说,对其左了封装,大致将可运行和运行合并为RUNNABLE,将休眠状态分解为WAITING、TIMED_WAITING、BLOCKED,还有一个注意点:

线程调用阻塞API,如等待I/O时,在Java层面是处于RUNNABLE,在OS层面处于休眠状态,因为JVM不关心OS调度相关状态,在JVM看来,等待CPU和等待I/O没有区别。

Java线程状态的转换

终止线程

stop()方法弃用,建议使用interrupt()来打断线程,通知它让它决定要不要结束,以及结束前的后续工作。

被interrupt的线程有怎么收到通知,有两种方式:

1、线程等待状态时,该线程被打断会抛异常,捕获异常处理自己的业务逻辑。

2、在自己线程中利用interrupted或isInterrupted自主检查是否被打断。

interrupted:Thread的静态方法,会清除中断标志位

isInterrupted():Thread的成员方法,不会清除中断标志位

注意点:抛出一个InterruptedException异常,会重置中断标志位

创建多少线程才合适

为什么需要多线程

提高性能:吞吐量(单位时间处理请求数,空间维度)和 延迟(处理一个请求的耗时,时间维度)

同等条件下,延迟越短,吞吐量越大,所以我们要提高吞吐量,降低延迟

如何提高性能

如何"提高吞吐量,降低延迟",一个方向算法优化,一个方向是将硬件的利用率提升到极致。

OS 解决了单一硬件的利用率,而并发编程就需要解决 CPU和I/O设备综合利用率问题(多线程)。

假如CPU计算和 I/O操作的耗时比时1:1,那么单一线程下,CPU计算时就不能 I/O操作,在 I/O操作就不能CPU计算,CPU和 I/O 利用率都只有50%,如果是两个线程,CUP计算时就可以 I/O操作,CPU和 I/O 利用率都只有100%.

多少线程合适

I/O密集型(I/O 操作执行的时间相对于 CPU 计算来说都非常长):理论上线程的数量 =CPU 核数 ,工程上线程的数量 =CPU 核数 +1

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

I/O 耗时和 CPU 耗时的比值是一个关键参数,不幸的是这个参数是未知的,而且是动态变化的,所以工程上,我们要估算这个参数,然后做各种不同场景下的压测来验证我们的估计。原则还是将硬件的性能发挥到极致。

为什么局部变是线程安全的

方法调用: 每调用一个方法,都会在调用栈创建一个栈帧,利用栈结构解决方法调用问题。

局部变量: 在方法内部,也就是在调用栈的栈帧中。

调用栈与线程: 每个线程都有独立的调用栈。

所以局部变量为什么是线程安全的

因为每个线程都有自己的调用栈,局部变量保存在各个线程的调用栈里,不会共享,自然就没有线程安全。

线程封闭: 仅在单线程内访问数据。由于不存在数据共享,就不会有并发问题。

如从数据库连接池获取连接Connection,利用ThreadLocal使只有一个线程可以访问。

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

封装共享变量

对共享变量和对其操作封装在类中,对外提供读写共享变量的同步方法,如下面的Account类就是一个线程安全的类。

java 复制代码
public class Account {
​
  private long value;
​
  public synchronized long get(){
​
    return this.value;
​
  }
​
  public synchronized void set(long value){
​
    this.value = value;
​
  }
​
}

对于不需要修改的成员变量,建议用final修饰,可以避免并发问题。

识别共享变量的约束条件

识别出所有共享变量的约束条件,如何加以处理,不然很容易出现竞态条件的并发问题,反应在代码里,基本上都会有if语句。

制定并发访问策略

避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。

不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。

管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

理论基础模块热点问题答疑

用锁的最佳实践

一个合理受保护的资源与锁的关联关系应该是N:1,共享一把锁。

锁应该是私有的、不可变、不可重用(如不要用String、Integer、Long、Boolean)

相关推荐
dhxhsgrx33 分钟前
PYTHON训练营DAY25
java·开发语言·python
不知几秋2 小时前
数字取证-内存取证(volatility)
java·linux·前端
chxii4 小时前
5java集合框架
java·开发语言
yychen_java5 小时前
R-tree详解
java·算法·r-tree
JANYI20186 小时前
嵌入式设计模式基础--C语言的继承封装与多态
java·c语言·设计模式
xrkhy6 小时前
反射, 注解, 动态代理
java
Ten peaches6 小时前
Selenium-Java版(操作元素)
java·selenium·测试工具·html
lyw2056197 小时前
RabbitMQ,Kafka八股(自用笔记)
java
邹诗钰-电子信息工程7 小时前
嵌入式自学第二十一天(5.14)
java·开发语言·算法
有梦想的攻城狮7 小时前
spring中的@MapperScan注解详解
java·后端·spring·mapperscan