Java并发编程
并发编程
线程与进程
进程和线程的概念
进程
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。进程就算用来加载指令、管理内存、管理IO的
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程。
线程
- 一个进程之内可以分为多个线程
- 一个线程就是一个指令流,将指令流的一条条指令以一定顺序交给CPU执行
- Java中,线程作为最小调度单位,进程作为资源分配的最小单位。在windows中进程是不活动的,只是作为线程的容器。
并发和并行的概念
并行(parallel)是同一时间动手做(doing)多件事情的能力。
并发(concurrent)是同一时间应对(dealing with)多见事情的能力。
线程基本应用
应用之异步调用
从方法调用的角度来讲,如果
- 需要等待结果返回,才能继续运行就是同步
- 不需要等待结果返回,就能继续运行就算异步
应用之提高效率
java线程
创建和运行线程
-
直接使用Thread
-
使用Runnable配合Thread
lambda简化
Runnable r = () -> log.debug("running"); Thread t = new Thread(() -> {log.debug("running");}, t2);
原理之Thread与Runnable的关系
- 创建线程的方法1是把线程和任务合并在一起,方法2是把线程和任务分开
- 用Runnable更容易与线程池等高级API配合
- 用Runnable让任务类脱离了Thread继承体系,更灵活
-
FutureTask配合Thread
FutureTask能够接受Callable类型的参数,用来处理有返回结果的情况
查看线程
观察多个线程同时运行
- 交替执行
- 先后顺序不受我们控制
查看进程方法
windows
- 任务管理器可以查看进程也可以杀死进程
- tasklist查看进程
- taskkill杀死进程
linux
- ps -fe查看所有进程
- ps -fT -p
查看某个进程(PID)的所有线程 - kill杀死进程
- top按大写H切换是否显示线程
- top -H -p
查看某个进程(PID)的所有线程
Java
- jps命令查看所有Java进程
- jstack
查看某个Java进程(PID)的所有线程 - jconsole查看某个Java进程中线程的运行情况(图形界面)
线程API
原理之线程运行
栈与栈帧
我们都知道JVM(Java虚拟机栈)中由堆、栈、方法区所组成,其中栈内存是给线程使用的,每个线程启动后,虚拟机都会为其分配一块栈内存。
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法
线程上下文切换(Thread Context Switch)
因为以下一些原因导致cpu不再执行当前的线程,转而执行另一个线程的代码
- 线程的cpu时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了sleep、yield、wait、join、park、synchronized、lock等方法
当上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,java中对应的概念就算程序计数器(Program Counter Register),作用是记住下一条jvm指令的执行地址,是线程私有的。
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Context Switch频繁发生会影响性能
常见方法
方法名 | 功能说明 | 注意 |
---|---|---|
start() | 启动一个新线程,在新线程运行run方法中的代码 | start方法只是让现场进入就绪,里面代码不一定立刻运行(CPU时间片还没分配)。每个线程的start方法只能调用一次,调用多次会出现IllegalThreadStateException |
run() | 新线程启动后调用的方法 | 如果构造Thread对象时传递了Runnable参数,则线程启动后会调用Runnable中的run方法,否则默认不执行任何操作。但可以创建Thread子类对象,来覆盖默认行为。 |
join() | 等待线程运行结束 | |
join(long n) | 等待线程运行结束,最多等待n毫秒 | |
getId() | 获取线程id | id唯一 |
getName() | 获取线程名 | |
setName() | 修改线程名 | |
getPriority() | 获取线程优先级 | |
setPriority() | 修改线程优先级 | java中规定线程优先级是1~10的整数,较大优先级能提高线程被CPU调度的几率 |
getState | java中线程状态是用6个enum表示,分别为NEW、RUNNABLE、BLOCKED、WAITING、TIME_WAITING、TERMINATED | |
isInterrupted() | 判断是否被打断 | 不会清除打断标记 |
isAlive() | 线程是否存活 | |
interrupt() | 打断线程 | 如果被打断线程正在sleep、wait、join,会导致被打断线程抛出InterruptedException,并清除打断标记;如果打断正在运行的线程,则会设置打断标记;park的线程被打断,不会设置打断标记 |
interrupted() | 判断当前线程是否被打断 | 会清除打断标记 |
currentThread() | 获取当前正在执行的线程 | |
sleep(long n) | 让当前执行的线程休眠n毫秒,休眠时让出cpu时间片 | |
yield() | 提示线程调度器让出当前线程对cpu的使用 | 主要为了测试和调试 |
sleep
- 调用sleep会让当前线程从Running进入Timed Waiting状态
- 其他线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
- 睡眠结束后的线程未必会立刻得到执行
yield
- 调用yield会让当前线程Running进入Runnable状态,然后调度执行其他同优先级的线程。如果这时没有同优先级的线程,那么不能保证让当前线程暂停的效果。
- 具体实现依赖于操作系统的任务调度器
join
interrupt
如果被打断线程正在sleep、wait、join,会导致被打断线程抛出InterruptedException,并清除打断标记;如果打断正在运行的线程,则会设置打断标记;park的线程被打断,不会设置打断标记
两阶段终止模式
Two Phase Termination
错误思路
- 使用线程对象的stop()方法停止线程
- stop方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
- 使用System.exit(int)方法停止线程
- 目的仅是停止一个线程,但这种做法会让整个程序停止
打断park线程不会清除打断状态
线程优先级
- 线程优先级会提示调度器优先调度该线程
- 如果cpu较忙,那么优先级高的线程会获取更多的时间片,但cpu闲时,优先级几乎没作用
主线程与守护线程
默认情况下,Java进程要等待所有线程都运行结束才会结束。有一种特殊的线程叫守护线程,只要其他非守护线程运行结束,即使守护线程代码没有执行完全,也会强制结束。
t1.setDaemon(true);
- 垃圾回收器线程就是一种守护线程
- Tomcat中Acceptor和Poller线程都是守护线程,所以Tomecat接收到shutdown命令后,不会等待它们处理完当前请求。
线程状态
新建new -> run() -> 就绪ready -> start() -> 运行running -> sleep()/wait() -> 阻塞waiting/time_waiting/blocked -> 销毁terminated
共享模型之管程
共享问题
问题分析
对于i++而言(i为静态变量),实际会产生如下的JVM字节码指令
getstatic i// 获取静态变量i的值
iconst_1 // 准备常量i
iadd // 自增
putstatic i// 将修改后的值存入静态变量i
临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题在多个线程访问共享资源
- 多个线程读共享资源也没问题
- 多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内存在对共享资源的多线程读写操作,称这段代码块为临界区
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。
synchronized 解决方案
应用之互斥
- 阻塞式的解决方案:synchronized, Lock
- 非阻塞式的解决方案:原子变量
java中互斥和同步都可以采用 synchronized 关键字完成,他们的区别:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后顺序不同,需要一个线程等待其他线程运行到某个点
sychronized(Object){
临界区
}
synchronized 实际是用对象锁保证临界区代码的原子性
- 如果把synchronized(obj)放在for循环外面,如何理解? --原子性
- 如果t1 synchronized(obj1)而t2 synchronized(obj2)会怎样运作? --锁对象
- 如果t1 synchronized(obj)而t2没有加会怎么样? --锁对象
方法上的synchronized
class Test{
public synchronized void test(){
}
}
等价于
class Test{
public void test(){
synchronized(this){
}
}
}
class Test{
public synchronized static void test(){
synchronized(Test.class){
}
}
}
等价于
class Test{
public static void test(){
synchronized(Test.class){
}
}
}
不加synchronized的方法 不能保证线程安全
线程八锁
变量的线程安全分析
成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根它们状态是否能改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
常见的线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- HashTable
- JUC包下的类
这里说的线程安全是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,可以理解为:
- 它们每个方法是原子的
- 但注意它们多个方法的组合不是原子的
线程安全类方法的组合
不可变类线程安全性
String、Integer等都是不可变类,因为其内部状态不可以改变,因此它们的方法都是线程安全的。
Monitor概念
Monitor 监视器/管程
java普通对象头Mark Word(32 bits)、Klass Word(32 bits)
每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,之后的Mark Word中就被设置指向Monitor对象的指针。结构如下:
-
刚开始Monitor中Owner为null
-
当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner
-
在Thread-2上锁的过程,如果Thread-3,Thread-4也来执行synchronized,就会进入EntryList BLOCKED
-
Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的。
-
途中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程。
-
Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态。
-
BLOCKED和WAITING线程都处于阻塞状态,不占用CPU时间片
-
BLOCKED线程会在Owner线程释放锁时唤醒
-
WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立即获得锁,而是进入EntryList重新竞争。
注意:
- synchronized必须是进入同一个对象的Monitor才有上述的效果。
- 不加synchronized的对象不会关联监视器,不遵从以上规则。
原理之synchronized
1、轻量级锁
使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁优化。
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
-
让锁记录中Object Reference 指向锁对象,并尝试使用CAS替换Object的Mark Word,将Mark Word值存入锁记录。
-
如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。
-
如果CAS失败,有两种情况
-
如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程。
-
如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数。
-
-
当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用CAS将Mark Word的值恢复给对象头。
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
2、锁膨胀
如何尝试加轻量级锁的过程中,CAS操作无法成功,这种情况就是其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
-
当Thread-1进行轻量级锁时,Thread-0已经对该对象加了轻量级锁
-
这时Thread-1加轻量级锁失败,进入锁膨胀流程
- 即为Object对象申请Monitor锁,让Object指向重量级锁地址
- 然后自己进入Monitor的EntryList BLOCKED
-
Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败。这时会进入重量级锁解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程
3、自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时持锁线程退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
- 在Java6之后自旋锁是自适应的,比如对象刚刚一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;繁殖就少自旋甚至不自旋
- 自旋会占用cpu时间,单核cpu自旋就是浪费,多喝cpu自旋才能发挥优势
- Java7之后不能控制是否开启自旋功能
4、偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要CAS操作。
Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID时自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归线程所有。
偏向状态
一个对象创建时
如果开启了偏向锁,那么对象创建后,Mark Word值为0x05即最后三位为101,此时它的thread、epoch、age都为0。如果没有开启偏向锁,那么对象创建后,Mark Word值为0x01即最后3位为001.这是它的hashCode、age为0,第一次用到hashCode才会赋值。
撤销-其他线程使用对象:当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
撤销-调用对象hashCode:调用了对象的hashCode,但偏向锁的对象Mark Word中存储的是线程id,如果调用hashCode会导致偏向锁被撤销。
- 轻量级锁会在锁记录中记录hashCode
- 重量级锁会在Monitor中记录hashCode
撤销-调用wait/notify
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程t1的对象仍有机会重新偏向t2,重偏向会重置对象的ThreadID
当撤销偏向锁阈值超过20次后,jvm会认为偏向错对象,于是会在给这些对象加锁时重新偏向至加锁线程。
批量撤销
当撤销偏向锁阈值超过40次后,jvm会觉得偏向错了,不应该偏向。整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
5、锁消除
原理之wait/notify
- Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WITING状态
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放锁时唤醒
- WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立刻获取锁,仍需进入EntryList重新竞争
API介绍
- obj.wait()让进入object监视器的线程到waitSet等待
- obj.notify()在object上正在waitSet等待的线程中挑一个唤醒
- obj.notifyAll()让object上正在waitSet等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于Object对象的方法。必须获取此对象的锁才能调用方法。
wait/notify的正确姿势
sleep(long n)和wait(long n)的区别:
- sleep是Thread方法,而wait是Object方法
- sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用
- sleep在睡眠的同事,不会释放对象锁,但wait在等待时候会释放对象锁
- sleep进入状态TIMED_WAITING,wait进入状态WAITING。
sychronized(lock){
while(条件不成立){
lock.wait
}
// 干活
}
// 另一个线程
synchronized(lock){
lock.notifyAll();
}
同步模式之保护性暂停
即Guarded Suspension,用在一个线程等待另一个线程执行结果。要点:
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个Guarded Object
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列
- JDK中,join、Future的实现就是采用此模式
- 因为要等待另一方的结果,因此归类到同步模式
原理之join
异步模式之生产者/消费者
要点:
- 与前面的保护性暂停中的GuardObject不同,不需要产生结果和消费结果的线程一一对应
- 消费队列可以用来平衡生产和消费的线程资源
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
- JDK中各种阻塞队列,采用的就算这种模式
Park & Unpark
它们是LockSupport类中的方法
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象);
特点,与Object的wait & notify 相比
- wait、notify和notifyAll必须配合Object Monitor一起使用,而park & unpark不必
- park & unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
- park & unpark可以先unpark,而wait & notify不能先notify
原理:每个线程都有自己的Parker对象,由三部分组成_counter, _cond 和 _mutex
- 线程就像一个旅人,Parker就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter就好比背包中的备用干粮(0为耗尽,1为充足)。
- 调用park就算要看需不需要停下来休息
- 如果备用干粮耗尽,那么钻进帐篷歇息
- 如果备用干粮充足,那么不需停留,继续前进
- 调用unpark,就好比令干粮充足
- 如果这时线程还在帐篷,就唤醒让他继续
- 如果线程还在运行,那么下次调用park时,仅时消耗掉备用干粮,不需停留
- 因为背包空间有限,多次调用unpark仅会补充一份备用干粮
- 当前线程调用Unsafe.park()方法
- 检查_counter,本情况为0,这时获得_mutex互斥锁
- 线程进入_cond条件变量阻塞
- 设置_counter=0
- 调用Unsafe.unpark(Thread_0)方法,设置_counter为1
- 唤醒_cond条件变量中的Thread_0
- Thread_0恢复运行
- 设置_counter为0
- 调用Unsafe.unpark(Thread_0)方法,设置_counter为1
- 当前线程调用Unsafe.park()方法
- 检查_counter,本情况为1,这时线程无需阻塞,继续运行
- 设置_counter为0
线程状态转换
多把锁
多把不相干的锁
将锁粒度细分
- 好处是可以增加并发度
- 坏处是如果一个线程需要同时获得多把锁,容易发生死锁
活跃性
死锁 可以使用顺序加锁的方式解决,但是可能产生饥饿的问题。
定位死锁
检测死锁可以使用jconsole工具,或者使用jps定位进程id,再用jstack定位死锁
活锁 出现在两个线程互相改变对方的结束条件,最后谁也无法结束。
饥饿 一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束。
ReentrantLock
和synchronized一样支持可重入,相比synchronized有以下特点
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
可重入 指同一个线程如果首次获得这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。
// 获取锁
reentrantLock.lock();
try{
// 临界区
}finally{
// 释放锁
reentrantLock.unlock();
}
可打断
// 可被interrupt方法打断
lock.lockInterruptibly();
锁超时
lock.tryLock(1,Timeunit.SECONDS)
公平锁
// 公平锁
ReentrantLock lock = new ReentrantLock(true);
条件变量支持多个条件变量
- await前需要获取锁
- await执行后会释放锁进入conditionObject等待
- await的线程被唤醒(或打断或超时)去重新竞争lock锁
- 竞争lock锁成功后,从await后继续执行
小结
使用synchronized互斥解决临界区的线程安全问题
- 掌握synchronized锁对象语法
- 掌握synchronized加载成员方法和静态方法语法
- 掌握wait/notify同步方法
使用lock互斥解决临界区的线程安全问题:可打断、锁超时、公平锁、条件变量
学会分析变量的线程安全性、掌握常见线程安全类的使用
活跃性问题:死锁、活锁、饥饿
应用方面
- 互斥:使用synchronized或Lock达到共享资源互斥效果
- 同步:使用wait/notify或Lock的条件变量来达到线程间通信效果
原理方面
- monitor、synchronized、wait/notify原理
- synchronized进阶
- park & unpark原理
模式方面
- 同步模式至保护性暂停
- 异步模型之生产者消费者
- 同步模式之顺序控制
共享模型之内存
Java内存模型JMM,即Java Memory Model,定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。JMM体现在以下几个方面
- 原子性-保证指令不会收到线程上下文切换的影响
- 可见性-保证指令不会受cpu缓存影响
- 有序性-保证指令不会受cpu指令并优化的影响
可见性
退不出的循环
static boolean run = true;
public static void main(String[] args) throws InterruptedException{
Thread t = new Thread(() -> {
whie(run){
//...
}
});
t.start();
Thread.sleep(1000);
log.debug("停止t");
run = false;// 线程t不会如预想的停下来
}
-
初始状态,t线程刚开始从主内存读取了run的值到工作内存。
-
因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的告诉缓存中,减少堆贮存中run的访问,提高效率
-
1秒后,main线程修改了run的值并同步至内存,而t是从自己工作内存中的告诉缓存中读取整个变量的值,结果永远是旧值
解决方法
volatile(易变关键字)它可以用来修饰成员变量和静态变量,可以避免线程从自己工作缓存中查找变量的值,必须从主内存中读取,线程操作volatile变量都是直接操作主存。
synchronized语句块既可以保证代码块的原子性,也可以保证代码块内变量的可见性。但是缺点是synchronized属于重量级操作,性能相对更低。
原子性
一个线程对volatile变量的修改对另一个线程可见,但不能保证原子性。
注意
synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低。
有序性
JVM会在不影响正确性的前提下,调整语句的执行顺序。这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。
两阶段终止模式
使用共享变量的方式优雅的结束线程利用isInterrupt、利用停止标记
同步模式之犹豫模式
Balking 用在一个线程发现另一个线程已经做了某一件相同的事,那么本线程无需再做了,直接结束返回。
JVM会在不影响正确性的前提下,可以调整语句的执行顺序。
volatile原理
volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对volatile变量的写指令后会加入写屏障
- 对volatile变量的读指令前会加入读屏障
保证可见性
-
写屏障(sfence)保证在该屏障之前的,对共享变量的改动都同步到主存中。
-
读屏障(lfence)保证在该屏障之后,读共享变量的读取,加载的是主存中最新数据。
保证有序性
-
写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后
-
读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前
但不能解决指令交错
-
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证跑到它前面去
-
而有序性的保证也只是保证本线程内相关代码不被重排序
double-checked locking DCL问题
如果一个类始终只能创建一个实例,那么这个类被称为单例类,这种设计模式被称为单例模式。
public final class Singleton{
private Singleton(){}
private static /*volatile*/ Singleton INSTANCE = null;
public static Singleton getInstance(){
if(INISTANCE == null){
synchronized(Singleton.class){
if(INSTANCE == null){
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
- 懒惰实例化
- 首次使用getInstance()才使用synchronized加锁,后续使用无需加锁
- 有隐含的:第一个if使用了INSTANCE变量,是在同步代码块外面。
- 代码块内的字节码会根据JVM优化而自动重排。而外面的if语句不是加锁的,在多线程环境下,第一个线程执行INSTANCE=new Singleton();有可能先赋值在调用Singleton对象的构造方法。而第二个线程在这时进入方法,判断INSTANCE不为null,直接范围对象,而实际上INSTANCE引用对象还没有初始化。
解决:volatile通过内存屏障可以解决指令重排的问题
happens-before
规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下hasppens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对共享变量的读可见。
-
线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对变量的读可见
static int x; static Object m = new Object(); new Thread(() -> { synchronized(m){ x = 10; } },"t1").start(); new Thread(() -> { synchronized(m){ System.out.println(x); } },"t2").start();
-
线程对volatile变量的写,读接下来其他线程对改变了的读可见
volatile static int x; new Thread(() -> { x = 10; },"t1").start(); new Thread(() -> { System.out.println(x); },"t2").start();
-
线程start前对变量的写,对该线程开始后对该变量的读可见
static int x; x = 10; new Thread(() -> { System.out.println(x); },"t2").start();
-
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束)
static int x; Thread t1 = new Thread(() -> { x = 10; },"t1"); t1.start(); t1.join(); System.out.println(x);
-
线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见
static int x; public static void main(String[] args){ Thread t2 = new Thread(() -> { while(true){ if(Thread.currentThread().isInterrupted()){ System.out.println(x); break; } } },"t2"); t2.start(); new Thread(() -> { Thread.sleep(1000); x = 10; t2.interrupt(); }).start(); while(!t2.isInterrupted()){ Thread.yield(); } System.out.println(x); }
-
对变量默认值(0,false,null)的写,对其他线程对该变量的读可见
-
具有传递性
volatile static int x; static int y; new Thread(() -> { y = 10; x = 20; },"t1").start(); new Thread(() -> { System.out.println(x); },"t2").start();
线程安全单例
// 问题1:为什么加final
// 问题2:如果实现了序列化接口,还要做什么来防止反序列化破坏单例
public final class Singleton implements Seriablizable{
// 问题3:为什么设置为私有?是否能防止反射创建新的实例?
private Singleton{}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全?
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将INSTANCE设置为public,说出你知道的理由
public static Singleton getInstance(){
return INSTANCE;
}
}
小结
可见性
有序性
happens-before原则
原理方面
- CPU指令并行
- volatile
模式方面
- 两阶段终止模式的volatile改进
- 同步模式之Balking
共享模型之无锁
CAS和volatile
AtomicInteger balance = new AtomicInteger();
public void withdraw(Integer amount){
while(true){
int prev = balance.get();
int next = prev - amount;
if(balance.compareAndSet(prev,next)){
break;
}
}
}
CAS必须借助volatile才能读取到共享变量的最新值来实现【比较并交换】的效果
CAS特点:结合CAS和volatile可以实现无锁并发,适用于线程数少,多核CPU的场景。
- CAS时基于乐观锁的思想
- synchronized时基于悲观锁的思想
- CAS体现的时无锁并发,无阻塞并发,
- 因为没有使用synchronized,所以线程不会陷入阻塞,这时效率提升的因素之一
- 如果竞争激烈,重试必然频繁发生,效率反而受影响
原子整数
JUC并发包提供了AtomicBoolean、AtomicInteger、AtomicLong
updateAndGet & getAndUpdate
原子引用
- AtomicReference
- AtomicMarkableReference
- AtomicStampedReference
主线程仅能判断出共享变量的值与最初值A是否相同,不能感知到从A改为B又改为A的情况(ABA问题)。如果主线程希望:只要有其他线程【动过】共享变量,那么自己的CAS就失败。这时,比较值是不够的,还需要再加一个版本号。
AtomicStampedReference可以给原子引用加上版本号,追踪原子引用整个的变化过程,可以知道变量中途被更改了几次。如果不关心引用变量更改了几次,只是单纯的关系是否更改过,则可以使用AtomicMarkableReference。
原子数组
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
字段更新器
- AtomicReferenceFieldUpdater
- AtomicIntegerFieldUpdater
- AtomicLongFieldUpdater
原子累加器
- LongAdder
- LongAccumulator
// 累加单元数组,懒惰初始化
transient volatile Cells[] cells;
// 基础值,如果没有竞争,则用cas累加这个域
transient volatile long base;
// 在cells创建或扩容时,置为1,表示加锁
transient volatile int cellsBusy;
原理之伪共享
因为CPU与内存速度差异很大,需要靠预读数据至缓存来提升效率。
而缓存以缓存行为单位,每个缓存行对应一块内存,一般64个字节。
缓存的加入造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。
CPU耀保证数据一致性,如果某个CPU核心更改了数据,其他CPU核心对应的整个缓存行必须失效。
因为Cell是数组形式,在内存中连续存储的,一个Cell为24字节(16字节的对象头,和8字节的value),因此缓存行可以存下2个cell对象。那么问题来了:
- Core-0要修改Cell[0]
- Core-1要修改Cell[1]
无论谁修改成功,都会导致对方Core的缓存行失效。
@sun.misc.Contended 为了解决缓存行伪共享的问题。原理是使用此注解的对象或字段的前后方各增加128字节大小的padding,从而CPU将对象预读至缓存时占用不同的缓存行。
Unsafe
Unsafe对象提供了非常底层的,操作内存、线程的方法,Unsafe对象不能直接调用,只能通过反射获得。
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
函数式编程
// supplier 提供者 无中生有 ()->结果
// function 函数 一个参数一个结果 (参数)->结果,BiFunction(参数1,参数2)->结果
// consumer 消费者 一个参数没结果(参数)->void,BiCosumer(参数1,参数2)->
小结
CAS和volatile
API
- 原子整数
- 原子引用
- 原子数组
- 字段更新器
- 原子累加器
Unsafe
原理
- LongAdder源码
- 伪共享
共享模型之不可变
日期转换的问题
SimpleDateFormat不是线程安全的,多线程时有很大几率出现java.lang.NumberFormatException或者出现不正确的日期解析结果。
不可变类的使用
DateTimeFormeatter对象是immutable、Thread-safe的。
不可变类设计
String类也是不可变的。
final的使用
String类、类中所有属性都是final的
- 属性用final修饰保证了该属性是只读的,不能修改
- 类用final修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
保护性拷贝
使用字符串时,有一些跟修改相关的方法,比如substring等,其内部是调用String的构造方法创建一个新字符串。构造方法构造新字符串对象时,会生成新的char[] value,对内容进行复制。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】
模式之享元
享元模式Flyweight pattern,当需要重用数量有限的同一类对象时
包装类
在JDK中Boolean、Byte、Short、Integer、Long、Character等包装类提供了valueOf()方法,例如Long的valueOf会缓存-128~127之间的对象,在这个范围之间会重用对象,大于这个范围才会新建Long对象。
String串池
BigDecimal BigInteger
原理之final
设置final变量的原理:字节码发现final变量赋值也是通过putfield指令完成,指令之后加入写屏障,保证其他线程读它的时候不会出现0的情况
获取final变量的原理(结合字节码)
无状态
没有任何成员变量的类是线程安全的。因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
共享模型之并发工具
线程池
ThreadPoolExecutor
线程池状态
ThreadPoolExecutor使用int的高三位来表示线程池状态,低29位表示线程数量
状态名 | 高3位 | 接受新任务 | 处理阻塞队列任务 | 说明 |
---|---|---|---|---|
RUNNING | 111 | Y | Y | |
SHUTDOWN | 000 | N | Y | 不会接受新任务,但会处理阻塞队列剩余任务 |
STOP | 001 | N | N | 会中断正在执行的任务,并抛弃阻塞队列任务 |
TIDYING | 010 | - | - | 任务全执行完毕,活动线程为0,即将进入终结 |
TERMINATED | 011 | - | - | 终结状态 |
数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING
构造方法
-
corePoolSize 核心线程数目(最多保留的线程数)
-
maximumPoolSize 最大线程数目
-
keepAliveTime 生存时间
-
unit 时间单位
-
workQueue 阻塞队列
-
threadFactory 线程工厂
-
handler 拒绝策略
-
线程池刚开始没有线程,当一个任务提交给线程池后, 线程池会创建一个新线程来执行任务
-
当线程数达到corePoolSize并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue队列排队,直到有空闲的线程
-
如果队列选择了有界队列,那么任务超过了队列大小时,会创建maximumPoolSize-corePoolSize数目的线程来救急。
-
如果线程到达maximumPoolSize仍然有新任务这时执行拒绝策略。拒绝策略jdk提供了4中实现:
- AbortPolicy抛出RejectedExecutionExeception异常,默认策略
- CallerRunsPolicy让调用者自己选择如何运行任务
- DiscardPolicy放弃本次任务
- DiscardOldestPolicy放弃队列中最早的任务,本任务取代之
-
高峰过去后,超过corePoolSize的救济线程如果一段时间没有任务做,需要结果线程节省资源,这一时间keepAliveTime和unit控制
newFixedThreadPool
- 核心线程数 == 最大线程数(没有救急线程),无需超时时间
- 阻塞队列是无界的,可以放任意数量任务
newCachedThreadPool
- 核心线程数为0,最大线程数是Integer.MAX_VALUE,救急线程的空闲生存时间为60s
- 全部都是救急线程(60s后可以回收)
- 救急线程可以无线创建
- 队列采用synchronousQueue实现特点是,没有容量,没有线程来取是放不进去的(一手交钱一手交货)
newSingleThreadExecutor
- 多个任务排队执行,线程数固定为1,任务书多于1时放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放
提交任务
// 执行任务
void execute(Runnable command);
// 提交任务task,用返回值Future获得任务执行结果
<T> Future<T> submit(Callable<T> task);
// 提交tasks中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
// 提交tasks中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException;
// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;
// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException;
关闭线程池
/*
线程池状态变为SHUTDOWN
- 不会接受新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
void shutdown();
// 尝试终结
tryTerminate();
// 不在RUNNING状态的线程池,此方法返回true
boolean isShutdown();
// 线程池状态是否是TERMINATED
boolean isTerminated();
// 调用SHUTDOWN后,由于调用线程并不会等待所有任务运行结束,因此如果它像在线程池TERMINATED后做些事情,可以利用此方法等待
boolean awaitTerminated(long timeout, TimeUnit unnit) throws InterruptedException;
异步模式之工作线程
让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务,也可以归类为分工模式。典型实现就算线程池。
对比另一个多线程设计模式(Thread-Per-Message)。
不同任务类型应该使用不同的线程池,这样能避免饥饿,并能提升效率。
饥饿
固定大小线程池会有饥饿现象
- 两个工人是同一线程池中的两个线程
- 他们要做的事情,点餐和做菜,这是两个阶段的工作
- 点餐:必须先点餐,等菜做好再上菜,此期间点餐工人必须等待
- 做菜
- 同时来了两个客人,工人A和工人B都处理点餐,没人做饭,饥饿现象。
创建多少线程池合适
- 过小回导致程序不能充分利用系统资源、容易导致饥饿
- 过大会导致更多的线程上下文切换,占用更多内存
CPU密集型运算
通常使用CPU核数+1能实现最优的CPU利用率,+1是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证CPU时钟周期不被浪费
I/O密集型运算
线程数 = 核数 * 期望CPU利用率 * 总时间(CPU计算时间+等待时间) / CPU计算时间
任务调度线程池
java.util.Timer可以实现定时功能,Timer优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务执行,前一个任务的延迟或异常都将会影响到之后的任务。
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
pool.schedule(() -> {},1,TimeUnit.SECONDS);
正确处理线程池异常
Future<boolean> f = pool.submit(() -> {
int i = 1 / 0;
return true;
});
System.out.println(f.get());
Tomcat线程池
- LimitLatch用来限流,用来控制最大连接个数,类似JUC中的Semaphore
- Acceptor只负责【接受新的socket连接】
- Poller只负责监听socket channel是否有【可读的I/O事件】
- 一旦可读,封装一个任务对象(socketProcessor),提交给Executor线程池处理
- Executor线程池中的工作线程最终负责【处理请求】
Tomcat线程池扩展了ThreadPoolExecutor
- 如果总线程达到maximumPoolSize
- 这是不会立刻抛出RejectedExecutionException异常
- 而是再次尝试放入队列,如果还失败,才抛出异常
Fork/Join
Fork/Join是1.7加入的新的线程池实现,体现了分治思想,使用与能够进行任务拆分的cpu密集型运算
使用
提交给Fork/Join线程池的任务需要继承RecursiveTask(有返回值)或RecursiveAction(无返回值)。
JUC
AQS原理
AbstractQueuedSynchronizer,是阻塞式锁和相关同步器工具的框架
- 用state属性表示资源状态(独占模式和共享模式),子类需要定义如何维护中国状态,控制如何获取锁和释放锁
- getState-获取状态
- setState-设置状态
- compareAndSetState-CAS机制设置state状态
- 独占模式只有一个线程能够访问资源,共享模式可以允许多个线程访问资源
- 提供了基于FIFO的等待队列,类似于Monitor的EntryList
- 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的WaitSet
ReentrantLock原理
非公平锁原理
可重入原理
可打断原理
不可打断模式
在此模式下,即使它被打断,仍会驻留在AQS队列中,等获得锁后方能继续运行(打断标记被设置为true)
可打断模式
在park过程中如果被interrupt会抛出异常不会再次进入死循环
公平锁原理
与非公平锁主要区别在于tryAcquire方法实现,公平锁会先检查AQS队列中是否有前驱节点,没有才竞争
条件变量实现原理
await流程
signal流程
读写锁
线程安全集合类
Semaphore
CountdownLatch
CyclicBarrier
ConcurrentHashMap原理
LongAdder value = map.computeIfAbsent(key,(key) -> new LongAdder());
value.increment;
JDK 7 HashMap并发死链复现
- 究其原因,是因为在多线程环境下使用非线程安全的map集合
- JDK8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味着能够在多线程环境下能安全扩容,还会出现其他问题(如扩容丢数据)