并发编程学习笔记

学习王老师的并发编程教程笔记,想要具体王老师的教程的同学可以私信我,仅用于交流学习

java并发 牵扯到的问题:
  • 可见性、原子性、有序性
  • 源头之一: 缓存导致的可见性的问题
    一个线程对共享变量的修改,另外一个线程可以立刻看到,这称为可见性。
  • 源头之二: 线程切换带来的原子性问题
    我们把一个或者多个操作再CPU 执行过程中不被中断的特性称为原子性。
  • 源头之三: 编译优化带来的有序性问题
    经典案例:双重锁检查
java 内存模型:看Java 如何解决可见性和有序性为题

解决方案:按需禁用缓存以及编译优化

  • java内存模型是一个很复杂的规范
    java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile 、synchronized 和 final 三个关键字, 以及六项 Happens - Before 规则
  • 使用 volatile 的困惑
    C语言中,它最初的原始意义就是为了就用CPU 缓存。
    Java 内存模型在1.5 版本对 volatile 语义进行了增强,怎么增强的呢- - -通过Happens-Before 原则。
  • Happens-Before 原则
    表达的意思是:前面的一个操作的结果对后面是可见的
    Happens-Before 约束了编译器的优化行为,虽允许编译起优化,但要求编译器优化以后必须遵守Happens-Before 规则。
  1. 程序的顺序性规则
    程序前面对某个变量的修改一定是对后续操作可见的。
  2. volatile 变量规则
    这条规则是指对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作
  3. 传递性
    这条规则是指如果 A Happens-Before B, 且 B Happens-Before C, 那么 A Happens-before C。
  4. 管程中锁的规则
    这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
    管程是一种通用的同步原语,在Java 中指的就是 synchronized , synchronized 是Java 里对管程的实现。
  5. 线程 start() 规则
    这条是关于线程启动的,它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
  6. 线程 join() 规则
    这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
  • 被我们忽视的final
    final 修饰变量时,初衷是告诉编译器:这个变量生而不变,可以使劲优化
互斥锁(上):解决原子性问题

原子性的源头是线程切换
同一时刻只有一个线程执行 这个条件非常重要,我们称为互斥

  • 简易锁模型
    我们把一段需要互斥执行的代码称为临界区 ,线程进入临界区之前,首先尝试加锁 lock(), 如果成功,则进入临界区,此时我们称这个线程持有这个锁; 否则就等待,知道持有锁的线程解锁; 持有锁的线程执行完临界区的代码之后,执行解锁 unlock()。
  • 改进后的锁模型
    锁和锁保护的资源是有对应关系的
    我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;其次,我们要保护资源 R 就得为它创建一把锁 LR;最后,针对这把锁 LR,我们还需在进出临界区时添上加锁操作和解锁操作。另外,在锁 LR 和受保护资源之间,我特地用一条线做了关联,这个关联关系非常重要。很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里我们认为已经正确加锁了。
  • Java 语言提供的锁技术: synchronized
    锁是一种通用的锁技术, Java 语言提供的 synchronized 关键字, 就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。
    Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug
    synchronized 修饰方法的时候锁定的是什么呢?这也是Java 的一条隐式规则:
    1.当修饰静态方法时: 锁定的对象是当前类的 Class 对象。2. 当修饰非静态方法时,锁定的对象是当前实例对象this
  • 用 synchronized 解决 count += 1 问题
    synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
  • 锁和受保护资源的关系
    :受保护资源和锁之间的关联关系是 N:1 的关系
  • 总结
    加锁能够保证执行临界区代码的互斥性,这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
    synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
互斥锁(下): 如何用一把锁保护多个资源?

当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。

  • 保护没有关联关系的多个资源
    但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁
  • 保护有关联关系的多个资源
  • 使用锁的正确姿势
    用同一把锁来保护多个资源,只要我们的锁能覆盖所有受保护资源就可以。
  • 总结
    关键是要分析多个资源之间的关系。如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁,这个过程可以类比一下门票管理。
    关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征。
    原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。
一不小心就死锁了,怎么办
  • 向现实世界要答案
    银行各账户转账
    用两把锁就实现了,转出账本一把,转入账本另一把。在 transfer() 方法内部,我们首先尝试锁定转出账户 this(先把转出账本拿到手),然后尝试锁定转入账户 target(再把转入账本拿到手),只有当两者都成功时,才执行转账操作。
  • 没有免费的午餐
    细粒度锁。使用细粒度锁可以提高并行度,是性能优化的一个重要手段。
    但是,使用细粒度锁是有代价的,这个代价就是可能会导致死锁。
    死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
  • 如何预防死锁
    并发程序一旦发生死锁,一般没有特别好的办法,很多时候只能重启应用。因此,解决死锁问题最好的方法还是规避死锁。
    如何避免死锁呢? 只有下面这四个条件都发生时才会出现死锁
    1.互斥,共享资源 X 和 Y 只能被一个线程占用;
    2. 占有且等待,线程 T1 已经取得共享资源 X ,在等待共享资源 Y 的时候,不释放共享资源 X ;
    3. 不可抢占,其他线程不能强行抢占线程 T1 占有资源;
    4. 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
    反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
    其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
    1. 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
    2. 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
    3. 对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
用“等待-通知”机制优化循环等待

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

  • 完美的就医流程
    一个完整的等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程重新获取互斥锁知等待的线程,重新获取互斥锁。
  • 用 synchronized 实现等待 - 通知机制
    在Java 语言里,等待 - 通知机制可以有多种实现方式,比如Java 语言内置的 synchronized 配合 wait() 、notify() 、notifyAll() 这三个方法实现
    一个等待队列,同一时刻,只允许一个线程进入 synchronized 保护的临界区(这个临界区可以看作大夫的诊室),当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待(相当于患者分诊等待)。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
    在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。
    那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify()和 notifyAll() 方法。我在下面这个图里为你大致描述了这个过程,当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
    为什么说是曾经满足过呢?因为notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。这一点你需要格外注意。
    除此之外,还有一个需要注意的点,被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。
  • 小试牛刀:一个更好的资源分配器
  • 尽量使用 notifyAll()
    notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。
  • 总结
    等待 - 通知机制是一种非常普遍的线程间协作的方式。工作中经常看到有同学使用轮询的方式来等待某个状态,其实很多情况下都可以用今天我们介绍的等待 - 通知机制来优化。Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法可以快速实现这种机制。
安全性、活跃性、以及性能问题
  • 安全性问题
    什么是线程安全呢?其本质就是正确性,而正确性的含义就是程序按照我们期望的执行,当存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据
    当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发 Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)
    竞态条件(Race Condition): 所谓竞态条件,指的是程序的执行结果依赖线程执行的顺序。
    那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:
  • 活跃性问题
    所谓活跃性问题,指的是某个操作无法执行下去。我们常见的“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
    有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”。
    解决“活锁”的方案很简单,尝试等待一个随机的时间就可以了。
    那“饥饿”该怎么去理解呢?所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
    解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
    那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
  • 性能问题
    使用“锁”要非常小心,但是如果小心过度,也可能出“性能问题”。“锁”的过度使用可能导致串行化的范围过大,这样就不能够发挥多线程的优势了,而我们之所以使用多线程搞并发程序,为的就是提升性能。
    第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copyon-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好…
    第二,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。
    性能方面的度量指标有很多,王老师觉得有三个指标非常重要,就是:吞吐量、延迟和并发量
  1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  2. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  3. 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
  • 总结:
    并发编程是一个复杂的技术领域,微观上涉及到原子性问题、可见性问题和有序性问题,宏观则表现为安全性、活跃性以及性能问题。
管程:并发编程的万能钥匙
  • 什么是管程
    所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?
  • MESA 模型
    在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和MESA 模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA模型。
    在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。
    管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。管程 X 将共享变量 queue 这个队列和相关的操作入队 enq()、出队 deq() 都封装起来了;线程 A 和线程 B 如果想访问共享变量 queue,只能通过调用管程提供的 enq()、deq() 方法来实现;enq()、deq() 保证互斥性,只允许一个线程进入管程。不知你有没有发现,管程模型和面向对象高度契合的。估计这也是 Java 选择管程的原因吧。而王老师在前面章节介绍的互斥锁用法,其背后的模型其实就是它。
  • notify() 何时可以使用
    除非经过深思熟虑,否则尽量使用 notifyAll()。那什么时候可以使用 notify() 呢?需要满足以下三个条件:
  1. 所有等待线程拥有相同的等待条件;
  2. 所有等待线程被唤醒后,执行相同的操作;
  3. 只需要唤醒一个线程。
Java线程(上):Java线程的生命周期

在操作系统层面,线程也有“生老病死”,专业的说法叫有生命周期。对于有生命周期的事物,要学好它,思路非常简单,只要能搞懂生命周期中各个节点的状态转换机制就可以了。

  • 通用的线程生命周期
    通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
  1. 初始状态,指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。
  2. 可运行状态,指的是线程可以分配 CPU 执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配 CPU 执行。
  3. 当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态
  4. 运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权,休眠状态的线程永远没有机会获得 CPU 使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。
  5. 线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
除了简化合并,这五种状态也有可能被细化,比如,Java 语言里就细化了休眠状态

  • Java 中线程的生命周期

Java 语言中线程共有六种状态,分别是:

  1. NEW(初始化状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

这看上去挺复杂的,状态类型也比较多。但其实在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
其中,BLOCKED、WAITING、TIMED_WAITING 可以理解为线程导致休眠状态的三种原因。

  1. RUNNABLE 与 BLOCKED 的状态转换
    只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
  2. 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。
  3. RUNNABLE 与 TIMED_WAITING 的状态转换
    有五种场景会触发这种转换:
    a. 调用带超时参数的 Thread.sleep(long millis) 方法;
    b. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
    c. 调用带超时参数的 Thread.join(long millis) 方法;
    d. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
    e. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
    这里你会发现 TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。
  4. 从 NEW 到 RUNNABLE 状态
    Java 刚创建出来的 Thread 对象就是 NEW 状态,从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start()方法就可以。
  5. 从 RUNNABLE 到 TERMINATED 状态
    线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java 的 Thread 类里面倒是有个 stop()方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的姿势其实是调用 interrupt()方法。
    那 stop() 和 interrupt() 方法的主要区别是什么呢?
    stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 synchronized 隐式锁,也不会释放,那其他线程就再也没机会获得 synchronized 隐式锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用
    interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
    当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt()方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException异常。上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throwsInterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的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() 方法,检测是不是自己被中断了。
  • 总结:
    理解 Java 线程的各种状态以及生命周期对于诊断多线程 Bug 非常有帮助,多线程程序很难调试,出了 Bug 基本上都是靠日志,靠线程 dump 来跟踪问题,分析线程 dump 的一个基本功就是分析线程状态,大部分的死锁、饥饿、活锁问题都需要跟踪分析线程的状态。
Java 线程(中): 创建多少线程才是合适的?
  • 为什么要使用多线程?
    两个指标,它们就是延迟和吞吐量。
    我们所谓提升性能,从度量的角度,主要是降低延迟,提高吞吐量。
  • 多线程的应用场景
    要想“降低延迟,提高吞吐量”,对应的方法呢,基本上有两个方向,一个方向是优化算法,另一个方向是将硬件的性能发挥到极致。前者属于算法范畴,后者则是和并发编程息息相关了。那计算机主要有哪些硬件呢?主要是两类:一个是 I/O,一个是 CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。
    我们的并发程序,需要 CPU 和 I/O 设备相互配合工作,也就是说,我们需要解决 CPU 和I/O 设备综合利用率的问题。
    关于这个综合利用率的问题,操作系统虽然没有办法完美解决,但是却给我们提供了方案,那就是:多线程
    如果 CPU 和 I/O 设备的利用率都很低,那么可以尝试通过增加线程来提高吞吐量。
    在单核时代,多线程主要就是用来平衡 CPU 和 I/O 设备的。如果程序只有 CPU 计算,而没有I/O 操作的话,多线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。但是在多核时代,这种纯计算型的程序也可以利用多线程来提升性能。为什么呢?因为利用多核可以降低响应时间。
  • 创建多少线程合适?
    创建多少线程合适,要看多线程具体的应用场景。我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,由于 I/O 设备的速度相对于 CPU 来说都很慢,所以大部分情况下,I/O 操作执行的时间相对于 CPU 计算来说都非常长,这种场景我们一般都称为 I/O 密集型计算;和 I/O 密集型计算相对的就是 CPU 密集型计算了,CPU 密集型计算大部分场景下都是纯 CPU 计算。I/O 密集型程序和 CPU 密集型程序,计算最佳线程数的方法是不同的。
    下面我们对这两个场景分别说明。
    对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 4 核的 CPU,每个核一个线程,理论上创建 4 个线程就可以了,再多创建线程也只是增加线程切换的成本。所以,对于 CPU 密集型的计算场景,理论上“程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
    对于 I/O 密集型的计算场景
    最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
  • 总结
    很多人都知道线程数不是越多越好,但是设置多少是合适的,却又拿不定主意。其实只要把握住一条原则就可以了,这条原则就是将硬件的性能发挥到极致。上面我们针对 CPU 密集型和 I/O密集型计算场景都给出了理论上的最佳公式,这些公式背后的目标其实就是将硬件的性能发挥到极致。
Java 线程(下):为什么局部变量是线程安全的?
  • 方法是如何被执行的
    CPU 去哪里找到调用方法的参数和返回地址?
    如果你熟悉 CPU 的工作原理,你应该会立刻想到:通过 CPU 的堆栈寄存器。CPU 支持一种栈结构,栈你一定很熟悉了,就像手枪的弹夹,先入后出。因为这个栈是和方法调用相关的,因此经常被称为调用栈
    例如,有三个方法 A、B、C,他们的调用关系是 A->B->C(A 调用 B,B 调用 C),在运行时,会构建出下面这样的调用栈。每个方法在调用栈里都有自己的独立空间,称为栈帧,每个栈帧里都有对应方法需要的参数和返回地址。当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。
  • 局部变量存哪里?
    局部变量就是放到了调用栈里。
    这个结论相信很多人都知道,因为学 Java 语言的时候,基本所有的教材都会告诉你 new 出来的对象是在堆里,局部变量是在栈里,只不过很多人并不清楚堆和栈的区别,以及为什么要区分堆和栈。现在你应该很清楚了,局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。
  • 调用栈与线程
    两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。因为如果不是这样,那两个线程就互相干扰了。
    Java 方法里面的局部变量是否存在并发问题?现在你应该很清楚了,一点问题都没有。因为每个线程都有自己的调用栈,局部变量保存在线程各自的调用栈里面,不会共享,所以自然也就没有并发问题。
  • 线程封闭
    方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。
    采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。
如何用面向对象思想写好并发程序?
  • 封装共享变量
    将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
    对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰。
  • 识别共享变量间的约束条件
    这些约束条件,决定了并发访问策略。
    一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙
  • 制定并发访问策略
  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java并发包提供的读写锁、并发容器等同步工具会更好。
Lock&Condition (上) :隐藏在并发包中的管程

Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。

  • 再造管程的理由
    synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
  • 如何保证可见性
    Java 里多线程的可见性是通过 Happens-Before 规则保证的, 而 synchronized 之所以能够保证可见性,也是因为有一条synchronized 相关的规则: synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。
    Java SDK 里面锁的实现非常复杂,利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock , 内部持有一个 volatile 的成员变量 state, 获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值(简化后的代码如下面所示)。也就是说,在执行 value+=1之前,程序先读写了一次 volatile 变量 state,在执行 value+=1 之后,又读写了一次 volatile变量 state。根据相关的 Happens-Before 规则:
  1. 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
  2. volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作Happens-Before 线程 T2 的 lock() 操作;
  3. 传递性规则:线程 T2 的 lock() 操作 Happens-Before 线程 T1 的 value+=1 。
  • 什么是可重入锁
    可重入锁,指的是线程可以重复获取同一把锁。
  • 可重入函数
    指多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程支持线程切换,无论被切换多少次,结果都是正确的。可重入函数是线程安全的。
  • 公平锁和非公平锁
    在使用 ReentrantLock 的时候,ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。 fair 参数代表是锁的公平策略,如果传入true 就代表需要构造一个公平锁,反之则表示要构造一个非公平锁。
    在入口等待队列中,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁时,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略是谁等待时间长唤醒谁,很公平,如果是非公平锁,可能等待时间短的先被唤醒。
  • 用锁的最佳实践
  1. 永远只在更新对象的成员变量时加锁
  2. 永远只在访问可变的成员变量时加锁
  3. 永远不在调用其他对象的方法时加锁

第三条规则,因为调用其他对象的方法,不是很安全,也许调用的方法里面有线程sleep() 的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能。更可怕的是,“其他” 类的方法可能也会加锁,双重加锁可能会导致死锁。

Lock 和 Condition (下) :Dubbo 如何用管程实现异步转同步?

Java SDK 并发包里的 Lock 有别于 synchronized 隐式锁的三个特性:能够相应中断、支持超时和非阻塞地获取锁。
Condition 实现了管程模型里面的条件变量。
Java 语言内置的管程里只有一个条件变量,而 Lock & Condition 实现的管程是支持多个条件变量的,这是二者重要的区别。

  • 如何用两个条件变量快速实现阻塞队列呢?
    一个阻塞队列,需要两个条件变量,一个是队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队)。
  • 同步和异步
    同步,是 java 代码默认的处理方式。如果你想让你的程序支持异步,可以通过下面两种方式来实现:
  1. 调用方创建一个子线程,在子线程中执行方法调用,这种调节我们称为异步调用;
  2. 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接return, 这种方法我们一般称为异步方法。
Semaphore: 如何快速实现一个限流器?
  • 信号量模型
    信号量模型可以简单概括为:一个计数器,一个等待队列,三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问他们,这三个方法分别是:init()、down()、up() 。
    init () : 设置计数器的初始值。
    down() : 计数器的值减 1 ;如果此时计数器的值小于 0 ,则当前线程被阻塞,否则当前线程可以继续执行。
    up() : 计数器的值加 1 ;如果此时计数器小于或者等于 0 ,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
    这里三个方法都是原子性的,并且这个原子性是由信号量模型的实现方保证的。在 Java SDK 里面,信号量模型是由Semaphore 实现的
ReadWriteLock: 如何快速实现一个完备的缓存?

针对读多写少这种并发场景,Java SDK 并发包提供了读写锁-- ReadWriteLock , 非常用以使用,并且性能很好。

  • 什么是读写锁
  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要的区别就是读写锁允许多个线程同时读共享变量, 而互斥锁是不允许的,这是读写锁在读多写少的场景下性能优于互斥锁的关键 。但读写锁的写操作是互斥的。当一个线程在写共享变量时,是不允许其他线程执行写操作和读操作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值