第1章 并发编程的挑战
1.1 上下文切换
CPU通过给每个线程分配CPU时间片来实现单核CPU的多线程执行。切换前会保存上一个任务的状态,以便下次切换回这个任务的时候,可以再加载到这个任务的状态,任务从保存到加载的过程就是一次上下文的切换。
如何减少上下文切换?
无锁并发编程、CAS算法、使用最少线程和使用协程。
1.2 死锁
1.3 资源限制的挑战
资源限制是指在进行并发编程的时候,程序的执行速度受限于计算机的硬件资源或者软件资源。
第2章 Java并发机制的底层实现原理
2.1 volatile的应用
volatile的定义和实现原理
定义:
- Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
- 如果一个变量被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
实现原则:
- Lock前缀指令会引起处理器缓存回写到内存
- 一个处理器的缓存会写到内存会导致其他处理器的缓存无效
2.2 synchronized的实现原理与应用
2.2.1 Java对象头
MarkWord中记录了锁的类型:无锁01,偏向锁01(偏向锁标志1)、轻量级锁00、重量级锁10。
2.2.2 锁的升级与对比
1. 偏向锁:
- 当一个线程访问同步块并获取锁的时候,会在对象头和帧栈中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,秩序简单地测试一下对象头的MarkWordli是否存储着指向当前线程的偏向锁。
- 如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下MarkWord中偏向锁的表示是否设置成1(表示当前是偏向锁):
- 如果没有设置,则设置CAS竞争锁;
- 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
偏向锁的撤销:
- 偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
- 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着
- 如果线程不处于活动状态,则将对象头设置成无锁状态。
- 如果线程依然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁。
2. 轻量级锁
(1)轻量级锁加锁
- 在线程执行同步块之前,JVM会先在当前线程中将对象头MarkWord复制到栈的锁记录(Lock Record)中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针。
- 如果成功,当前线程获得锁
- 如果失败,表示有其他线程竞争锁,当前线程尝试使用自旋来获取锁
(2)轻量级锁解锁
- 轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有发生竞争。如果失败,表示当前所锁存在竞争,锁会膨胀成,锁就会膨胀成重量级锁。
3. 重量级锁
线程会发生阻塞,因为Java的线程和内核线程是1:1对应的关系,阻塞的时候会导致用户态到内核态的切换,开销比较大,因此叫做重量级锁。
2.3 原子操作的实现原理
1. 术语定义:
缓存行:
- 缓存的最小操作单位
比较并交换(CAS):
- CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换新值,发生了变化则不交换。
CPU流水线:
- CPU流水线将一条指令分成5~6步,每一步都由不同的逻辑单元进行处理,这样就能实现在一个CPU时钟周期完成一条指令。
内存顺序冲突:
- 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。
2. 处理器如何实现原子操作
i++
是经典的读改写操作,如果多个处理器执行i++
操作的话,就可能导致最后的结果和预期不一致。例如当i=1时,执行两个i++操作,这两个操作分别由两个cpu执行,其都读取到i=1到缓存中,执行计算之后缓存中的i=2,写入共享内存中后i=2,与预期i=3的结果不符合。因此要想保证原子操作,在CPU1读写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
-
实现总线锁保证原子性:
- 当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞。
- 缺点:锁定期间,其他处理器不能操作其他内存地址的数据
-
使用缓存锁保证原子性
- 所谓缓存锁定,即内存区域如果被缓存在处理器的缓存行中,当他执行回写操作的时候,会使得该内存区域的其他处理器的缓存行无效。
3. Java如何实现原子操作
Java中可以通过锁和循环CAS的方式来实现原子操作
CAS实现原子操作的三大问题:
- ABA问题:变量从A变成B又变回了A,使用CAS进行检查的时候会发现他和原来的值没有发生变化。可以通过在变量前面追加版本号来解决。
- 循环时间长,开销大
- 只能保证一个共享变量的原子操作
第3章 Java内存模型
3.1 Java内存模型的基础
3.1.1 并发编程模型的两个关键问题
- 线程之间如何通信
- 线程之间如何同步
3.1.2 Java内存模型的抽象结构
主内存:
- 即所有线程共享的内存:包括实例域、静态域和数组元素
本地内存:
- 线程独有的内存:保存了该线程读/写共享变量的副本
如果线程A和线程B之间要进行通信的话
1)线程A把本地内存A中更新过的共享变量刷新到主内存中
2)线程B到主内存中去读取线程A之前已更新过的共享变量
3.1.3 从源代码到指令序列的重排序
为了提高性能,编译器和处理器常常会对指令做重排序
- 1)编译器优化的重排序
- 2)指令级并行的重排序(指令级并行技术指对多条指令重叠执行)
- 3)系统内存的重排序(处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去在乱序执行)
3.1.4 并发编程模型的分类
- 现代处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器等待向内存中写入数据而产生的延迟。但是写缓冲区仅对他所在的处理器可见。
- 这个特性会对内存操作的执行顺序产生重要的影响:处理器队内存的读/写操作的执行顺序,并不一定与内存实际发生读/写操作顺序一致!
内存屏障:用来禁止处理器重排序,4中类型如下:
- LoadLoad:Load1;LoadLoad;Load2,确保Load1读操作在Load2之前
- StoreStore:Store1;StoreStore;Store2,确保Stroe1刷新到内存操作在Store2刷新到内存操作之前
- LoadStore:Load1;LoadStore;Store2,确保Load1数据装载先于Store2及所有后序的存储指令刷新到内存
- StoreLoad:Store1;StoreLoad;Load2, 确保Store1数据对其他处理器变得可见(指刷新到内存)
3.1.5 happens-before 简介
JSR-133内存模型用happens-before的概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要队另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
与程序员密切相关的规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后序操作
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
- volatile变量规则:对一个volatile域的写,happens-before于任意后序对这个volatile域的读
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
注意:两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before关系仅仅要求前一个操作(执行的结果)对后一个操作可见。
3.2 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
3.2.1 数据依赖性
如果两个操作访问同一个变量,且者两个操作中有一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会对存在数据依赖性关系的两个操作进行重排序。
3.2.2 as-if-serial 语意
含义:
- 不管怎么样排序,(单线程)程序的执行结果不能被改变。
3.2.3 程序顺序规则
软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,经可能提高并行度。
3.2.4 重排序对多线程的影响
线程A的两个操作不存在依赖关系,线程B的两个操作不存在依赖关系,但是线程B的操作和线程A的操作存在依赖关系。因此线程A和线程B各自可能发生重排序,会影响到多线程的程序语义。(见书中的图)
3.3 顺序一致性
3.3.1 数据竞争与顺序一致性
数据竞争:
- 当一个线程中写一个变量
- 在另一个线程读同一个变量
- 而且写和读没有通过同步来排序
顺序一致性:
- 如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与改成在顺序一致性内存模型中的执行结果相同。
3.3.2 顺序一致行内存模型
特征:
- 一个线程中的所有操作必须按照程序的顺序来执行
- 所有线程都只能看到一个单一的操作执行顺序。在顺序一致性模型中,每隔操作都必须原子执行且立即队所有线程可见。
3.3.3 同步程序的顺序一致性效果
使用synchronized来保证程序的同步
3.3.4 未同步程序的执行特性
未同步程序在JMM上面执行时,整体上是无序的,其执行结果无法预知。
3.4 Volatile 的内存语义
3.4.1 volatile 的特性
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性
3.4.2 volatile 写-读建立的 happens-before 关系
从内存语义的角度来说,volatile的写-读与锁的释放-获取有相同的内存效果。
3.4.3 写-读的内存语义
volatile写的内存语义:
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
volatile读的内存语义:
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接受了之前某个线程发出的消息。
- 线程A写一个volatile变量,然后线程B读这个volatile变量实质上是线程A通过主内存向线程B发送消息
3.4.4 volatile 内存语义的实现
- 通过在写/读操作间加入内存屏障。
3.4.5 为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。这可能会导致多线程之间顺序一致性问题。(见书中例子)
3.5 锁的内存语义
3.5.1 锁的释放-获取建立的happens-before关系
writer方法和Reader方法都是用同一个锁同步
线程A执行writer方法,线程B执行reader方法,则A happens-before B
3.5.2 锁的释放和获取的内存语义
- 当线程释放锁的时候,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,则其必须从主内存中读取共享变量。
即:
- 锁释放与volatile写有相同的内存语义
- 锁获取与volatile读有相同的内存语义
3.5.3 锁内存语义的实现
ReentrantLock通过调用lock()方法获取锁;调用unlock()方法释放锁
当设置为公平锁时:
lock()方法最底层源码:
- 获取锁的开始,首先读取volatile变量state
- 当state为0时,表示锁未被占用。通过CAS操作改写state的值,如果成功,则表示获取到了锁。
- 当state不为0时,表示锁已经被占用。因此会判断占用该锁的线程是否为自身,如果为自身的话,则继续增加state的状态值
unlock()方法最底层源码:
- 先判断该线程是否为锁的占有者
- 如果为锁的占有者,则设置volatile变量 state 为 state - release(这里不直接设置为0是因为同一个线程可能多次获取了该锁)
- 如果不是锁的占有者,则抛出异常
当设置为非公平锁时:
lock()方法:
- 直接通过CAS操作更新volatile变量state,这个操作同时具有volatile读和写的内存语义
3.5.4 concurrent包的实现
concurrent包中源码的通用化模式:
- 首先,声明变量为volatile
- 然后,使用CAS原子条件更新来实现线程之间的同步
- 同时,配合volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信
3.6 final域的内存语义
3.6.1 final的重排序规则
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序
3.6.2 写final域的重排序规则
- 在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了
3.6.3 读final域的重排序规则
- 在读一个对象的final域之前,一定会先读包含这个final域对象的引用
3.6.4 final域为引用类型
见示例
3.6.5 为什么final引用不能从构造函数内“溢出”
为了保证某对象的final域在任意线程可见之前,其已经被正确初始化过了。所以在构造函数内部,final域被构造之前,不能让这个对象的引用“逸出”。
3.6.6 final语义在处理器中的实现
使用内存屏障保障
3.7 happens-before
3.7.1 JMM的设计
设计的关键:
- 程序员:希望内存模型易于理解,易于编程
- 编译器和处理器:对其束缚越少越好,这样他们就可以做尽可能多的优化来提高性能
策略:
- 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
- 对于不会改变程序执行结果的重排序,JMM队编译器和处理器不做要求(JMM允许这种重排序)
3.7.2 happens-before的定义
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。
3.7.3 happens-before规则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后序操作
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
- volatile变量规则:对一个volatile域的写,happens-before于随后对这个volatile域的读
- 传递性:如果A happens-before B, B happens-before C,则A happens-before C
- start()规则,如果线程A执行操作ThreadB,start()启动线程B,则线程A启动操作happens-before于线程B中的任意操作。
- join()规则,如果A执行操作ThreadB.join(), 则线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回
3.8 双重检查锁定与延迟初始化
3.8.1 双重检查锁定的由来
双重检查锁定:
-
对于一个单例对象的创建getInstance()方法,先判断对象是否为null,如果为null的话,则创建该对象。但是如果多线程执行这个创建方法,可能会创建多个对象。
-
解决的方法是给这个方法加锁。但是加锁会导致不管对象是否创建都会先去获取锁,性能开销会变得很大。因此在锁的外面再加上一个判断对象是否为null的语句。这样子如果对象不为null就不需要去获取锁了,从而降低了开销。
-
但是这个方法可能有一个致命的错误,就是通过外层的判断对象不为null时,对象可能还没有初始化完成,就能够获取到这个对象并进行使用了,会导致错误。
延迟初始化:
- 为了降低复杂类初始化的开销,将一些字段的初始化延迟。
3.8.2 问题的根源
创建对象语句可能会重排序。
创建对象语句实际上包含3个步骤:
- 分配对象的内存空间
- 初始化对象
- 设置instance指向刚分配的内存地址
编译器可能会重排序:将2和3颠倒顺序
- 分配对象的内存空间
- 设置instance指向刚分配的内存地址
- 初始化对象
因此在到达步骤2的时候如果外层就判断到对象不为null了,但是实际上对象还没进行初始化。
3.8.3 基于volatile的解决方法
将instance变量指定为volatile,这样子就能防止初始化instance的时候编译器对指令进行重排序了。
3.8.4 基于类初始化的解决方法
JVM在类的初始化阶段,会执行类的初始化。再执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以指定静态类static class IntanceHolder 在其里面初始化static isntance,这样在获取单例instance的时候会先去初始化InstanceHolder这个类,就获得了类的初始化锁,这样子就能够解决上述的问题。
对比:
- 基于类的初始化方案的实现代码更加简洁。
- 基于volatile变量的双重检查锁定的额外优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
总结:
- 字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。大多数时候,正常的初始化要优于延迟初始化。
- 如果需要队实例字段使用线程安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;
- 如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
3.9 Java内存模型综述
3.9.1 处理器内存模型
不同的处理器其内存模型的特征也不一样,对内存的读/写操作的执行顺序有不一样的标准。JMM对不同的处理器进行了处理,屏蔽了不同处理器的差异,在不同的平台上为Java程序员提供了一个一致的内存模型。
3.9.3 JMM的内存可见性保证
- 单线程程序
- 正确同步的多线程程序
- 未同步/未正确同步的多线程程序。提供了最小安全性保障:读取的值要么是之前线程写入的值,要么是默认值(0,null,false)
第4章 Java并发编程基础
4.1 线程简介
4.1.1 什么是线程
现代操作系统调度的最小单元是线程
4.1.2 为什么要使用多线程
- 现代处理器核心数量越来越多,为了更好地利用处理器提高程序执行效率
- 更快的响应时间
- 更好的编程模型
4.1.3 线程优先级
Java语言提供了10个线程优先级,可以通过thread.setPrority(1-10)来设置优先级,但是操作系统不一定会理会Java设置的优先级。
4.1.4 线程的状态
- NEW:初始状态,线程被构建,但是还没有调用start()方法
- RUNNABLE:运行状态,Java线程将操作系统的就绪和运行统称为“运行中”
- BLOCKED:阻塞状态,表示线程阻塞于锁
- WAITING:等待状态,表示线程进入等待状态,进入等待状态表示需要等待其他线程做出一些特定动作(通知或中断)
- TIME_WAITING:超时等待,可以在指定的时间自行返回
- TERMINATED:终止状态,表示当前线程已经执行完毕
4.1.5 Daemon 线程
Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机不存在非Daemon线程时,Java虚拟机将会退出。可以通过Thread.setDaemon(true)将线程设置为Daemon状态。
4.2 启动和终止线程
线程通过调用start()方法而启动,随着run()方法的执行完毕线程也随之终止。
4.2.1 构造线程
运行线程之前需要先构造一个线程对象,并在调用start()之前设置线程所需要的属性。
4.2.2 启动线程
调用start()方法
4.2.3 理解中断
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。
别的线程可以通过调用指定线程的interrupt()方法来中断该线程,线程通过isInterrupted()进行判断是否被中断。
Java中许多声明InterruptedException的方法在抛出该异常之前,会将线程的中断标志位清除,再抛出异常,此时调用isInterrupted()方法将会返回false。
4.2.4 过期的 suspend(), resume() 和 stop()
- suspend():暂停线程,调用后不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,容易发生死锁
- resume():恢复线程
- stop():终止线程,终结一个线程后不会保证线程资源的正常释放,可能导致程序工作在不确定状态下
基于suspend()和stop()的特性,因此不建议使用
4.2.5 安全地终止线程
可以利用一个boolean变量来控制是否需要停止任务并终止该线程。
4.3 线程间通信
4.3.1 volatile 和 synchronized 关键字
Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程拥有这个变量的拷贝(现代多核处理器的显著特征),所以程序在执行的过程中,一个线程看到的变量并不一定是最新的。
-
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对改变量的访问均需要从共享变量中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
-
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程队变量访问的可见性和排他性。
4.3.2 等待/通知机制
Object类中的方法:
- notify():通知一个对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁
- notifyAll():通知所有等待在该对象上的线程
- wait():调用该方法的线程进入WAITING状态,只有等待另外线程的通知或被终端才会返回,需要注意,调用wait()方法后,会释放对象锁
- wait(long):超时等待一段时间,这里参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回
- wait(long, int):对于超时时间更细粒度的控制,可以达到纳秒
等待/通知机制:
- 是指一个线程线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后序操作。
注意细节:
- 使用 wait()、notify() 和 notifyAll() 时需要先对调用对象加锁
- 调用 wait() 方法后,线程状态由RUNNABLE变为WAITING,并将当前线程放置到等待队列
- notify() 或 notifyAll() 方法调用后,等待线程依旧不会从wait()返回,需要调用notify() 和 notifyAll() 的线程释放锁之后,等待线程才有机会从wait()放回
- notify() 方法将等待队列中的一个等待线程从等待队列移到同步队列中,notifyAll() 方法将等待队列中的所有线程全部移到同步队列,被移动的线程状态从WAITING变为BLOCKED。
- 从wait() 方法返回的前提是获得了调用对象的锁
4.3.3 等待/通知的经典范式
等待方:
- 获取对象的锁
- 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
- 条件满足则执行对应的逻辑
通知方:
- 获得对象的锁
- 改变条件
- 通知所有等待在对象上的线程
4.3.4 管道输入/输出流
管道输入/输出流和普通文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。
4.3.5 Thread.join() 的使用
含义:
- 如果A线程执行了thread.join()语句,表示当前线程等待thread线程终止之后才从thread.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis) 和 join(long millis, int nanos) 两个具备超时特性的方法。
4.3.6 ThreadLocal的使用
ThreadLocal 即线程变量,是一个以ThreadLocal对象为键,任意对象为值得存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值
4.4 线程应用实例
4.4.1 等待超时模式
调用一个方法等待一段时间,如果该方法能够在给定的时间段之内得到结果,那么将结果立刻返回,反之,超时返回默认结果。
可以对等待/通知的经典范式稍加修改即可实现上面的场景。
4.4.2 一个简单的数据库连接池示例
如果客户端获取连接的等待时间超过特定值,则返回connection为null
4.4.3 线程池技术及其示例
对于服务端程序,如果每次接受一个任务,都需要创建一个线程然后进行执行,如果面对的是成千上万的任务递交进服务器时,那么则会创建数以万计的线程,这不是一个好的选择。
- 因为这会使操作系统频繁地进行线程上下文切换,无故增加系统的负载
- 而线程的创建的创建和消亡都需要耗费系统资源的,无疑也浪费了系统的资源
线程池技术能够很好地解决这个问题。一个方面消除了频繁的进行线程上下文切换,另一方面,消除了频繁创建和消亡线程的系统资源开销。
线程池例子:
- 通过一个队列来存储job,线程池内的线程拿出队列中的job来执行。
- 本质就是使用了一个线程安全的工作队列连接工作者线程和客户端线程,客户端线程将任务放入工作队列后便返回,而工作者线程则不断地从工作队列上取出工作并执行。
- 当工作队列为空时,所有的工作者线程均等待在工作队列上,当有客户端提交了一个任务之后会通知任意一个工作者线程,随着大量的任务被提交,更多的工作者线程会被唤醒。
4.4.4 一个基于线程池技术的简单Web服务器
通过4.4.3节中实现的线程池完成了一个简单的web服务器。
第5章 Java中的锁
5.1 Lock接口
通过Lock接口可以更灵活地实现获取锁和释放锁的操作lock.lock()、lock.unlock()
使用lock.lock()时需要使用try finally,在finally调用lock.unlock()避免抛出异常没有释放锁。
Lock接口提供的synchronized关键字不具备的主要特性:
- 尝试非阻塞地获取锁
- 能被中断地获取锁
- 超时获取锁
5.2 队列同步器
队列同步器 AbstractQueuedSynchronizer 是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
5.2.1 队列同步器的接口与示例
队列同步器的设计是基于模板方法模式的。
重写方法时需要使用同步器提供的方法:
- getState(): 获取当前同步状态
- setState(int newState): 设置当前同步状态
- compareAndSetState(int expect, int update): 使用CAS设置当前状态,该方法能够保证状态设置的原子性
同步器可重写的方法:
- tryAcquire: 独占式获取同步状态
- tryRelease:独占式释放同步状态
- tryAcquireShared:共享式获取同步状态
- tryReleaseShared:共享式释放同步状态
- isHeldExclusively:当前同步器是否在独占式模式下被占用
同步器提供的模板方法:
- acquire:独占式获取同步状态,会调用重写的tryAcquire方法
- acquireInterruptibly:与acquire方法相同,但是会响应中断
- tryAcquireNanos:在acquireInterruptibly的基础上增加了超时限制
- acquireShared:共享式获取同步状态,会调用重写的trySharedAcquire方法
- acquireSharedInterruptibly:与acquireShared方法相同,但是会响应中断
- tryAcquireSharedNanos:在acquireSharedInterruptibly的基础上增加了超时限制
- release:独占式释放同步状态
- getQueueThreads:获取等待在同步队列上的线程集合
5.2.3 队列同步器的实现分析
1.同步队列
- 同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
2.独占式同步状态获取与释放
- 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头结点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease方法释放同步状态,然后唤醒头结点的后继节点。
3.共享式同步状态获取与释放
概念:
- 共享式访问资源时,其他共享式的访问均被允许,而独占式访问被阻塞
- 独占式访问资源时,同一时刻其他访问均被阻塞
- 以文件的读写为例,共享式用于读操作,而独占式用于写操作
4.独占式超时获取同步状态
- 增加了时间限制,超时会直接返回
5.自定义同步组件——TwinsLock
- 通过定义资源数来保证在同一时刻,只允许至多2个线程同时访问
5.3 重入锁
重入锁 ReentrantLock 概念:
- 就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
- 如果一个锁不支持重入功能,则该线程获取锁之后再次获取锁,则该线程会被自己阻塞。
公平锁和非公平锁:
- 如果在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁是公平的,否则是不公平的。
ReentrantLock是如何实现重进入和公平性获取锁的呢?
1.实现重进入
- 线程再次获取锁:锁需要去识别获取锁的线程是否为当前占据锁的线程
- 锁的最终释放:线程获取锁的时候会对状态进行计数自增,释放锁的时候会对状态计数自减
2.公平与非公平获取锁的区别
- 公平锁通过FIFO队列实现
- 非公平锁则是通过CAS设置锁的状态,如果成功则获取到了锁
- 公平锁保证了锁的获取是按照FIFO原则,而代价是进行大量的线程切换。非公平可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
5.4 读写锁
读写锁维护了一对锁:
- 读锁:读锁支持其他读操作获取读锁,写操作获取锁则会被阻塞
- 写锁:写锁被获取后,其他读操作和写操作获取锁时都会被阻塞
特征:
- 支持公平与非公平
- 支持重进入
- 支持锁降级:将读锁降级为写锁
5.4.1 读写锁的接口与示例
ReentrantReadWriteLock
5.4.2 读写锁的实现分析
1.读写状态的设计
- 通过一个32位的读写锁状态S维护,高16位表示读状态,低16位表示写状态
- 当前写状态为 S & 0x0000FFFF(将高16为全部抹去)
- 当前读状态为 S>>>16 (无符号补0右移16位)
- 当读状态+1时,等于 S+1
- 当写状态+1时,等于 S+(1<<<16),即S+0x00010000
2.写锁的获取与释放
- 如果当前线程已经获取了写锁,则增加写状态。
- 如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
3.读锁的获取与释放
- 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
- 如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁
- 读锁的每次释放均减少读状态,减少值为(1<<16)
4.锁降级
- 锁降级指的是写锁降级成为读锁
- 如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级
- 锁降级是指把持住当前的写锁,再获取到读锁,随后释放写锁的过程
- 锁降级中为什么要先获取读锁再释放写锁呢?主要是为了保持数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
5.5 LockSupport 工具
当需要阻塞或者唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作
- park(): 阻塞线程
- unpark(): 唤醒线程
- parkNanos(): 阻塞当前线程,最长不超过nano纳秒
- parkUtil(): 阻塞当前线程,直到deadline时间
5.6 Condition接口
- 任意一个Java对象都拥有一组监视器方法wait(), wait(long timeout), notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。
- Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式
5.6.1 Condition 接口与示例
- Condition condition = lock.newCondition(): 获取condition
- condition.await(): 当前线程进入等待状态
- condition.signal(): 其他线程调用通知当前线程,当前线程才从await()方法返回,并且在返回前已经获取了锁
5.6.2 Condition 的实现分析
1.等待队列
- 等待队列是一个FIFO队列,如果一个线程调用了Condition.await()方法,那么该线程将会释放锁,构造成节点加入等待队列并进入等待状态。
2.等待
- 调用 Condition 的 await() 方法,会使当前线程进入等待队列并释放锁,同时线程状态变为等待状态
- 调用await()方法相当于将同步队列的首节点(获取了锁的节点)移动到 Condition 的等待队列中
3.通知
- 调用 Condition 的 signal()方法,将会唤醒在等待队列中等待时间最长的节点(首节点),在将节点唤醒之前,会讲节点移动到同步队列中
第6章 Java并发容器和框架
6.1 ConcurrentHashMap 的实现原理与使用
6.1.1 为什么要使用 ConcurrentHashMap
1.线程不安全的 HashMap
- HashMap可能导致程序死循环中:在扩容的过程中复制原来的链表到新的table里面使用头插法,当两个线程同时开始扩容的时候,就会导致链表头尾连接在一起,形成环装结构,Entry 的 next 节点永远不为空,就会产生死循环获取Entry。
2.效率低下的HashTable
- HashTable 使用 synchronized来保证线程安全。当一个线程访问HashTable的同步方法时,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态,效率非常低下。
3.ConcurrentHashMap 的锁分段技术可以有效地提升并发访问率
- HashTable容器效率低下的原因是所有访问HashTable的线程都竞争同一把锁
- 假如容器里有多把锁,分别负责其中的一部分数据,那么多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争的关系,从而提高了并发访问效率。这就是ConcurrentHashMap的锁分段技术。
6.1.2 ConcurrentHashMap 的结构
- 一个ConcurrentHashMap里包含一个Segment数组
- Segment的结构和HashMap类似,是一种数组和链表结构。
- 一个Segment里面包含了一个HashEntry数组,每个HashEntry是一个链表结构的元素
- 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁
6.1.3 ConcurrentHashMap 的初始化
1.初始化segments 数组
- segments数组的长度是2的N次方
2.初始化segmentShift和segmentMask
- 这两个全局变量需要在定位segment时的散列算法里使用
3.初始化每个segment
- initialCapacity是ConcurrentHashMap的初始化容量,通过initialCapacity可以算出每个segment里HashEntry数组的长度cap,loadfactor是每个segment的负载因子
6.1.4 定位Segment
通过两次散列来保证元素能够均匀地分布在不同的Segment上,从而提高容器的存取效率。
6.1.5 ConcurrentHashMap 的操作
1.get操作
-
get操作的高效在于整个get过程不需要加锁
-
原因:get方法里将要使用的共享变量都定义成volatile类型,因此可以被多线程读,但是只能被单线程写(happen before原则,对volatile变量的写入操作先于读操作)
2.put操作
- 为了保证线程安全,在操作共享变量之前会先加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经过两个步骤:
- 是否需要扩容:在插入元素之前先判断Segment里的HashEntry数组是否超过了容量,如果超过阈值,则队数组进行扩容
- 如何扩容:首先创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap 不会对整个容器进行扩容,而只对某个segment进行扩容
3.size操作
-
统计ConcurrentHashMap里的元素大小就是对每个segment的count进行累加
-
在累加count操作的过程中,之前累加过的count发生变化的几率非常小,所以先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如果统计过程中,容器的count发生了变化,则再采用加锁的方式来统计所有的Segment的大小
-
ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put、remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生变化,从而得知容器的大小是否发生变化
6.2 ConcurrentLinkedQueue
线程安全队列的实现有两种方式:
- 使用阻塞算法:可以用一个锁(入队和出队用同一个锁)或两个锁(入队和出队用不同的锁)来实现
- 非阻塞的实现方式使用循环CAS的方式来实现
6.2.1 ConcurrentLinkedQueue 的结构
ConcurrentLinkedQueue由head节点和tail节点组成,每个节点(Node)由节点元素(item)和指向下一个节点的(next)的引用组成。
6.2.2 入队列
1.入队列的过程
入队列就是将入队节点添加到队列的尾部,主要做两件事:
- 第一将入队节点设置成当前队列尾节点的下一个节点
- 第二是更新tail节点,如果tail节点的next节点不为空,则将入队节点设置成tail节点,如果tail节点的next节点为空,则将入队节点设置成tail的next节点,所以tail节点不总是尾节点
2.定位尾节点
- tail节点并不总是尾节点,每次入队都必须先通过tail节点来找到尾节点。
3.设置入队节点为尾节点
4.HOPS的设计意图
- 如果能减少CAS更新tail节点的次数,就能提高入队的效率,因此使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将tail节点更新程尾节点,而是当tail节点和尾节点的距离大于等于常量HOPS的值时才更新tail节点,tail和尾节点的距离越长,使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间久越长。
- 本质上来看它通过增加对volatile变量的读操作来减少对volatile变量的写操作来提高效率。
6.2.3 出队列
出队列就是从队列里返回一个节点元素,并清空该节点对元素的引用。
- 首先获取头结点元素,然后判断头结点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走
- 如果不为空,则使用CAS的方式将头结点元素的引用设置为null,如果CAS成功,则直接返回头结点的元素,不成功则表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头结点
6.3 Java 中的阻塞队列
6.3.1 什么是阻塞队列
阻塞队列是一个支持两个附加操作的队列:
- 支持阻塞的插入方法:当队列满时,队列会阻塞插入元素的线程,直到队列不满
- 支持阻塞的移除方法:当队列为空时,获取元素的线程会等待队列变为非空
阻塞队列的4种处理方式:
方法/处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
移除方法 | remove() | poll() | take() | poll(time, unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
6.3.2 Java 里的阻塞队列
1.ArrayBlockingQueue
- 一个由数组结构组成的有界阻塞队列
2.LinkedBlockingQueue
- 一个用链表实现的有界阻塞队列
3.PriorityBlockingQueue
- 一个支持优先级的阻塞队列,默认情况下升序排列。也可以自定义类实现compareTo()方法来指定元素排序规则。
4.DelayQueue
- 一个支持延时获取元素的无界阻塞队列。
- 队列中的元素必须实现Delayd接口,在创建元素时可以指定多久才能从队列中获取当前元素
运用场景:
- 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素,表示缓存有效期到了
- 定时任务调度:使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行
5.SynchronousQueue
- 一个不储存元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。
6.LinkedTransferQueue
- 由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列,LinkedTransferQueue多了tryTransfer和transfer方法
- transfer方法:如果当前有消费者正在等待接受元素,transfer方法可以把生产者传入的元素liketransfer给消费者。如果没有消费者在等待接受元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返回。
- tryTransfer方法:用来试探生产者传入的元素能否直接传给消费者。如果没有消费者等待接受元素,则返回false。
7.LinkedBlockingQueue
- 一个由链表结构组成的双向阻塞队列。
6.3.3 阻塞队列的实现原理
使用通知模式实现
- 使用 Condition 来实现通知生产者和消费者( condition.await()、condition.signal() )
- await()中通过LockSupport.park(this)来阻塞生产者,只有以下四种方法发生时,park方法才会返回:
- 与park对应的unpark被执行
- 线程被中断
- 等待完time参数指定的毫秒数时
- 异常现象发生时
6.4 Fork/Join框架
6.4.1 什么是Fork/Join框架
- 一个用于并行执行任务的框架,是一个把大任务分割成若干个小人物,最终汇总每个小任务结果后得到大任务结果的框架。
- Fork就是把一个大任务分割成若干个小任务
- Join就是合并这些子任务的执行结果
6.4.2 工作窃取算法
- 工作窃取算法是指某个线程从其他队列窃取任务来执行。
- 优点:充分利用线程进行并行计算,减少了线程间的竞争
- 缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务的时候。
6.4.3 Fork/Join框架的设计
- 步骤1 分割任务
- 步骤2 执行任务并合并结果
Fork/Join使用两个类来完成上面的两件事情
- ForkJoinTask:创建ForkJoin任务需要继承ForkJoinTask的子类:
- RecursiveAction:用于没有返回结果的任务
- RecursiveTask:用于有返回结果的任务
- ForkJoinPool:ForkJoinTask需要通过ForkJoinPool来执行
6.4.4 使用Fork/Join框架
- ForkJoinTask 与一般任务的主要区别在于它需要实现compute方法,在这个方法里,首先需要判断任务是否足够小,如果足够小就直接执行任务。如果不足够小,就必须分割成两个子任务,每隔子任务在调用fork方法时,又会进入compute方法。最后通过join方法会等待子任务执行完毕并得到其结果。
6.4.5 Fork/Join框架的异常处理
ForkJoinTask在执行的时候可能会抛出异常,但是我们没办法在主线程里直接捕获异常,所以ForkJoinTask提供了isCompletedAbnormally()方法来检查任务是否已经抛出异常或已经被取消了,并且可以通过getException方法获取异常。
6.4.6 Fork/Join 框架的实现原理
1.fork方法实现:
- 调用fork方法后会将该任务放到ForkJoinTask的数组队列里,然后再唤醒或创建一个工作线程来执行任务
2.join方法实现: - join方法的主要作用是阻塞当前线程并等待获取结果。其先调用了doJoin()方法,通过doJoin()方法得到当前任务的状态来判断返回什么结果。
- 已完成,直接返回任务结果
- 被取消,直接抛出CancellationException
- 抛出异常,直接抛出对应的异常
第7章 Java 中的 13 个原子操作类
- 当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量i=1,A线程更新i+1,B线程也更新i+1,经过两个线程的操作之后可能i不等于3,而是等于2。因为A线程和B县城在更新变量 i 的时候拿到的 i 都是1,这就是线程不安全的更新操作。
- 通常我们使用synchronized来解决这个问题,synchronized会保证多线程不会同时更新变量i。
- Java.util.concurrent.atomic包提供了原子操作类可以方便快捷、线程安全地更新一个变量。
7.1 原子更新基本类型类
- AtomicBoolean
- AtomicInteger
- AtomicLong
AtomicInteger:
- addAndGet:先增加数值再返回
- compareAndSet
- getAndIncrement:先返回数值再加1
- lazySet
- getAndSet:先返回数值,再设置为newValue的值
7.2 原子更新数组
通过原子的方式更新数组里的某个元素
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
AtomicIntegerArray:
- addAndGet(int i, int delta): 以原子的方式将输入值delta与索引i的元素想加
- compareAndSet(int i, int expect, int update)
7.3 原子更新引用类型
- AtomicReference:原子更新引用类型
- AtomicReferenceFieldUpdate:原子更新引用类型里的字段
- AtomicMarkableReference:原子更新带有标记位的引用类型
AtomicReference
- AtomicReference atomicUserRef = new AtomicReference<>();
- atomicUserRef.set(user)
- atomicUserRef.compareAndSet(user, updateUser);
7.4 原子更新字段类
- AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
- AtomicLongFieldUpdater:原子更新长整型字段的更新器
- AtomicStampedReference:原子更新带有版本号的引用类型。可以解决ABA问题
AtomicIntegerFieldUpdater:
- AtomicIntegerFieldUpdater a = AtomicIntegerFieldUpdater.newUpdater(User.class, “old”): 绑定User类中的old字段
- a.getAndIncrement(conan): 将conan的old字段增加1, 并返回旧年龄
- a.get(conan): 得到conan的年龄
第8章 Java中的并发工具类
8.1 等待多线程完成的CountDownLatch
CountDownLatch允许一个或多个线程等待其他线程的完成操作
- join:join用于让当前执行线程等待join线程执行结束
- CountDownLatch也可以实现jpin功能:CountDownLatch的构造函数接受一个int类型的参数作为计数器,调用cdl的countDown方法时,计数器就会减1,cdl的await方法会阻塞当前线程,直到计数器变为零。
8.2 同步屏障 CyclicBarrier
- CyclicBarrier是可循环使用的屏障。它让一组线程到达一个屏障时被阻塞,直到所有的线程到达屏障时,屏障才会开门,所有被拦截的线程才会继续执行。
8.2.1 CyclicBarrier简介
- 构造方法中的参数表示屏障拦截的线程数量,调用await方法则表示当前线程到达了屏障,会被阻塞
- 高级的构造函数CyclicBarrier(int parties,Runnable barrierAction)用于在线程全部到达屏障时,优先执行barrierAction
8.2.2 CyclicBarrier的应用场景
- 汇总sheet表格
8.2.3 CyclicBarrier和CountDownLatch的区别
- cdl的计数器只能使用一次,而cb的计数器可以使用reset方法重置
- 另外,cb还提供其它有用的方法:getNumberWaiting获取阻塞的线程数量,isBroken用来了解阻塞线程是否被中断
8.3 控制并发线程数的Semaphore
- 信号量Semaphore是用来控制同时访问特定资源的线程数量。
1.应用场景
- 流量控制,如数据库连接
- 用法:构造函数参数传入许可证数量,s.acquire获取一个许可证,s.release归还许可证。
2.其他方法
- availablePermits:返回此信号量中当前可用的许可证数
- getQueueLength:返回正在等待获取许可证的线程数
- hasQueueThreads:是否有线程正在等待获取许可证
- reducePermits:减少x个许可证
- getQueueThread:返回所有等待获取许可证的线程集合
8.4 线程间交换数据的Exchanger
- Exchanger是一个用于线程间协作的工具类,可以用于线程间的数据交换。
第9章 Java中的线程池
合理使用线程池能够带来的3个好处
- 降低资源消耗:通过重复利用已经创建的线程降低线程创建和销毁造成的消耗
- 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行
- 提高线程的可管理性
9.1 线程池的实现原理
线程池的处理流程:
- 线程池判断核心线程池里的线程是否已满,如果没满则创建新线程来执行任务
- 如果运行的线程等于或多于核心线程池(大于corePoolSize),则将任务加入阻塞队列
- 如果阻塞队列已满,则创建新的线程来处理任务
- 如果运行的线程都已经满了(线程数大于maximumPoolSize),则任务将会被拒绝并抛出异常
大部分的任务都会集中在步骤2中,不需要创建新的线程,意味着不需要获取全局锁(性能瓶颈)。线程池的总体设计思路就是尽可能避免获取全局锁。
9.2 线程池的使用
9.2.1 线程池的创建
通过ThreadPoolExecutor来创建一个线程池,参数为:
- corePoolSize:线程池的基本大小
- runnableTaskQueue:保存任务的阻塞队列
- maximumPoolSize:线程池的最大数量
- ThreadFactory:用于设置创建线程的工厂
- RejectExecutionHandler:饱和策略,当队列和线程池都满了,添加新任务时的策略
9.2.2 向线程池提交任务
- execute:用于提交不需要返回值的任务
- submit:用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过future的get方法来获取返回值,get方法会阻塞当前线程直到任务完成
9.2.3 关闭线程池
- shutdown:将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行的线程
- shutdownNow:先将线程池的状态设定为STOP,然后尝试停止所有的正在执行的或暂停任务的线程,并返回等待执行任务的列表
通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法
9.2.4 合理地配置线程池
分析角度:
- 任务性质:cpu密集型任务,io密集型任务。cpu密集型任务尽可能分配小的线程,防止cpu频繁调度切换上下文;io密集型任务尽可能分配多的线程,因为任务并不是一直在执行线程
- 任务的优先级:高,中和低。优先级不同可以使用优先级队列来处理
- 任务的执行时间:长,中和短。可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行
- 任务的依赖性:是否依赖其他系统资源,如数据库连接。因为线程提交sql后需要等待数据库返回结果,等待时间越长,cpu的空闲时间越长,因此线程数就要设置得越大。
建议使用有界队列。防止出现问题之后队列中的任务堆积导致内存溢出
9.2.5 线程池的监控
线程池提供一些方法用于监控:
- taskCount:线程池需要执行的任务数量
- completedTaskCount:线程池中已完成任务的数量
- largestPoolSize:线程池里曾经创建的最大线程数量
- getPoolSize:线程池的线程数量
- getActiveCount:获取活动的线程数
第10章 Executor框架
Java把工作线程和执行机制分离开来:
- 工作单元:Runnable、Callable
- 执行机制:Executor框架
10.1 Executor框架简介
10.1.1 Executor框架的两级调度模型
10.1.2 Executor框架的结构与成员
1.Executor框架的结构
- 任务:Runnable接口或Callable接口
- 任务的执行:Executor接口
- 异步计算的结果:Future接口
2.Executor框架的成院
- ThreadPoolExecutor:线程池
- ScheduledThreadPoolExecutor:用于周期任务的执行
3.Future接口
- 实现类FutureTask,用于表示异步计算的结果
4.Runnable接口和Callable接口
- Runnable接口不会返回结果
- Callable接口可以返回结果
10.2 ThreadPoolExecutor详解
主要的四个组件:corePool、maximumPool、BlockingQueue、RejectedExecutionHandler
10.2.1 FixedThreadPool详解
可重用固定线程数的线程池,将corePoolSize和maximumPoolSize都设置为nThread,执行步骤:
- 1)如果当前运行的线程数少于corePoolSize,则创建新线程来执行任务
- 2)在线程池完成预热之后,将任务加入LinkedBlockingQueue
- 3)线程执行完1中的任务后,会在循环中反复从LinkedBlockingQueue获取任务来执行
10.2.2 SingleThreadExecutor详解
使用单个worker线程的Executor,将corePoolSize和maximumPoolSize都设置为1,执行步骤:
- 1)如果当前运行的线程数少于corePoolSize,则创建一个新线程来执行任务
- 在线程池完成预热之后,将任务家入LinkedBlockingQueue
- 线程执行完1中的任务后,会在一个无限循环中反复从LinkedBlockingQueue获取任务来执行
10.2.3 CachedThreadPool详解
一个会根据需要创建新线程的线程池,将corePoolSize设置为0,maximumPoolSize设置为Integer.MAX_VALUE,线程空闲生存时间keepAliveTime设置为60s,执行步骤如下:
- 1)首先执行SynchronousQueue.offer向同步队列添加任务,如果有空闲线程正在同步队列中执行poll,则配对成功,将任务交给空闲线程执行。
- 2)如果没有空闲线程,步骤1失败,则会创建一个新线程执行任务
- 3)执行完毕后,空线程会到同步队列中执行poll方法,如果60s后还没有获得新任务,则该线程会被终止
10.3 ScheduledThreadPoolExecutor详解
主要用来给定的延迟之后运行,或者定期执行任务。
10.3.1 ScheduledThreadPoolExecutor的运行机制
队列使用DelayQueue实现,其中执行主要分为两大部分:
- 1)调用ScheduledThreadPoolExecutor的scheduleAtFixedRate()方法或者scheduleWithFiexdDelay()方法,向DelayQueue添加一个任务
- 2)线程池中的线程从DelayQueue中获取ScheduledFutureTask,然后执行任务
10.3.2 ScheduledThreadPoolExecutor的实现
任务ScheduledFutureTask主要包含三个变量:
- time:被执行的具体时间
- sequenceNumber:任务序号
- period:任务执行间隔周期
DelayQueue封装了一个PriorityQueue,这个PriorityQueue会对ScheduledFutureTask任务进行排序:
- 时间小的排在前面
- 时间相同时,sequenceNumber小的排在现面
执行步骤:
- 1)线程1从DelayQueue中获取到已到期的ScheduledFutureTask
- 2)执行
- 3)修改任务的time变量为下次执行的时间
- 4)线程将修改完time的任务放回DelayQueue中
线程获取任务的三大步骤:
- 1)获取Lock
- 2)获取任务周启
- 2.1如果队列为空,则当前线程到Condition中等待
- 2.2队列不为空,头元素的time比当前时间大,到Condition中等待
- 2.3头元素的time小于当前时间,获取该任务,如果此时队列不为空,则唤醒在Condition中等待的所有线程
- 3)释放Lock
线程添加任务到队列的三大步骤
- 1)获取Lock
- 2)添加任务
- 2.1向priorityQueue中添加任务
- 2.2如果上面添加的任务是PriorityQueue的头元素,唤醒在Condition中等待的所有线程
- 3)示方Lock
10.4 FutureTask详解
异步计算的结果,其还实现了Runnable接口
10.4.1 FutureTask简介
FutureTask的3种状态
- 1)未启动
- 2)已启动
- 3)已完成
10.4.2 FutureTask的使用
- 可以把FutureTask交给Executor执行
- 也可以通过ExecutorService.submit方法返回一个FutureTask,然后执行FutureTask.get()方法或者FutureTask.cancel方法
- FutureTask还能够单独使用
10.4.3 FutureTask的实现
基于AQS实现的同步器来实现(AQS提供通用机制来原子性管理同步状态、阻塞和管理线程,以及维护被阻塞线程的队列)
FutureTask.get()会去调用AQS.qcquireSharedInterruptibly方法,该方法的执行过程如下:
- 先回调在子类Sync种实现的tryAcquireShared()方法来判断acquire操作是否可以成功。
- 如果成功则get方法立刻返回,如果失败则到线程的等待队列去等待其他线程执行release操作
- 当其他线程执行release操作唤醒该线程后,当前线程再次执行tryAcquireShare将返回1,并唤醒等待队列中的后继线程(最终等待队列种的所有线程都会通过级联的方式被唤醒)
- 最后返回计算的结果或者抛出异常
FutureTask.run()执行过程如下:
- 执行在构造函数中指定的任务(Callable.call())
- 以原子方式来更新同步状态。如果该操作成功,则返回代表计算结果的result为Callable.call()的返回值,然后调用AQS.releaseShared方法
- AQS.releaseShared首先回调在子类Sync种实现的tryReleaseShared来执行release操作,然后唤醒等待对类中的第一个线程
- 调用FutureTask.done()
第11章 Java并发编程实战
11.1 生产者和消费者模式
在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序整体处理数据的速度。
生产者和消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通信,而是通过阻塞队列来进行通信。
11.1.1 生产者消费者模式实战
- Yuna工具:生产者启动一个线程将邮件放到阻塞队列,消费者启动CPU*2个线程从阻塞队列里取邮件并上传到wiki中。
11.1.2 多生产者和多消费者场景
- 多核时代,多线程并发处理速度比单线程处理速度更快,所以可以使用多个线程来生产数据,同样也可以使用多个线程来处理数据。
- 更复杂的情况是,消费者消费的数据,有可能需要继续处理,于是消费者处理完数据之后,又要作为生产者把数据放在新的队列里
11.1.3 线程池与生产消费者模式
Java中的线程池就是一种生产者和消费者模式的实现方式,但是其实现方式更加高明:
- 生产者将任务丢给线程池,线程池创建线程并处理任务
- 如果任务数量大于线程池的基本数量,则将任务扔到阻塞队列中
11.2 线上问题定位
很多问题只有在线上或者预发环境才能发现,而线上又不能调试代码,所以线上问题定位就只能查看日志、系统状态和dump线程
- 1)Linux下使用top命令查看每个进程的情况
- 2)再使用top的交互命令数字1查看每个CPU的性能数据
- 3)使用top的交互命令H查看每个线程的新能信息
11.3 性能测试
希望系统的某个接口能够支持2万的QPS:
- 性能测试工具原理:用户写一个Java程序向服务端发起请求,这个工具会启动一个线程池来调度这些任务,可以配置同时启动多少个线程、发起请求次数和任务间隔时长。将这个程序部署在多台机器上执行,统计出QPS和响应时长。
查看端口12200有多少个连接
- netstat -nat | grep 12200 -c
通过ps命令查看java进程的线程数是否增长了:
- ps -eLF | grep java -c
11.4 异步任务池
异步任务池的处理流程:(Quartz)
- 每台机器会启动一个任务池,每个任务池里有多个线程池
- 当某台机器将一个任务交给任务池后,任务池会先将这个任务保存到数据库中
- 然后某台机器上的任务池会从数据库中获取待执行的任务,再执行这个任务