1 线程池
1.1 概念
- 回顾线程创建方式
- 继承Thread
- 实现Runnable - 线程的状态
- NEW:刚刚创建,没做任何操作
Thread thread = new Thread();
System.out.println(thread.getState());
- RUNNABLE:调用run,可以执行,但不代表一定在执行(RUNNING,READY)
thread.start();
System.out.println(thread.getState());
- BLOCKED:抢不到锁
final byte[] lock = new byte[0];
new Thread(new Runnable() {
public void run() {
synchronized (lock) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
Thread thread2 = new Thread(new Runnable() {
public void run() {
synchronized (lock) {
}
}
});
thread2.start();
Thread.sleep(1000);
System.out.println(thread2.getState());
- WAITING
Thread thread2 = new Thread(new Runnable() {
public void run() {
LockSupport.park();
}
});
thread2.start();
Thread.sleep(500);
System.out.println(thread2.getState());
LockSupport.unpark(thread2);
Thread.sleep(500);
System.out.println(thread2.getState());
- TIMED_WAITING
Thread thread3 = new Thread(new Runnable() {
public void run() {
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread3.start();
Thread.sleep(500);
System.out.println(thread3.getState());
- TERMINATED
//等待1s后再来看
thread.sleep(1000);
System.out.println(thread.getState());
- 线程池基本概念
根据上面的状态,普通线程执行完,就会进入TERMINATED销毁掉,而线程池就是创建一个缓冲池存放线程,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等候下次任务来临,这使得线程池比手动创建线程有着更多的优势:
- 降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
- 提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
- 方便线程并发数的管控。因为线程若是无限制的创建,可能会导致内存占用过多而产生OOM;
- 节省cpu切换线程的时间成本(需要保持当前执行线程的现场,并恢复要执行线程的现场)。
- 提供更强大的功能,延时定时线程池。(Timer vs ScheduledThreadPoolExecutor)
- 常用线程池类结构
说明:
- 最常用的是ThreadPoolExecutor
- 调度用ScheduledThreadPoolExecutor
- Executors是工具类,协助你创建线程池的
1.2 工作机制
在线程池的编程模式下,任务是提交给整个线程池,而不是直接提交给某个线程,线程池在拿到任务
后,就在内部寻找是否有空闲的线程,如果有,则将任务交给某个空闲的线程。一个线程同时只能执行
一个任务,但可以同时向一个线程池提交多个任务。
- 线程池状态
- RUNNING:初始化状态是RUNNING。线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0。RUNNING状态下,能够接收新任务,以及对已添加的任务进行处理。
- SHUTDOWN:SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。
- STOP:不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。调用线程池的shutdownNow()接口时,线程池由(RUNNING 或 SHUTDOWN ) -> STOP
- TIDYING:所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING。线程池变为TIDYING状态时,会执行钩子函数terminated(),可以通过重载terminated()函数来实现自定义行为。
- TERMINATED:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING ->
TERMINATED
- 结构说明
- 任务的提交
- 添加任务,如果线程池中线程数没达到coreSize,直接创建新线程执行
- 达到core,放入queue
- queue已满,未达到maxSize继续创建线程
- 达到maxSize,根据reject策略处理
- 超时后,线程被释放,下降到coreSize
1.3 源码剖析
任务提交阶段:
// 任务提交阶段:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
// 判断工作数,如果小于corePoolSize,addWork,注意第二个参数core=true
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 否则,如果线程池还在运行,offer到队列
if (isRunning(c) && workQueue.offer(command)) {
// 再检查一下状态
int recheck = ctl.get();
// 如果线程池已经终止,直接移除任务,不再响应
if (! isRunning(recheck) && remove(command))
reject(command);
// 否则,如果没有线程干活,创建一个空work,该work会从队列获取任务去执行
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 队列也满了,继续调addWorker,但是注意,core=false
// 如果线程超出了maximumPoolSize,addWorker会返回false,进入reject
else if (!addWorker(command, false))
reject(command);
}
创建线程:
// 创建线程
private boolean addWorker(Runnable firstTask, boolean core) {
//第一步,计数判断,不符合条件打回false
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);
//判断线程数,注意这里!
//也就说明线程池的线程数是不可能设置任意大的。
//最大29位(CAPACITY=29位二进制)
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
//第二步,创建新work放入线程集合works(一个HashSet)
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//符合条件,创建新的work并包装task
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
//在这里添加线程!!!
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//注意,只要是成功add了新的work,那么将该新work立即启动,任务得到执行
t.start();
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
任务获取与执行:
//任务获取与执行
//在worker执行runWorker的时候,会调用getTask,获取任务
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
//判断是不是要超时处理,重点!!!决定了当前线程要不要被释放
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
//线程数超出max,并且上次循环中poll等待超时了,那么说明该线程已终止
//将线程队列数量原子性减
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
//重点!!!
//如果线程可被释放,那就poll,释放的时间为:keepAliveTime
//否则,线程是不会被释放的,take一直被阻塞在这里,直到来了新任务继续工作
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
//到这里说明可被释放的线程等待超时,已经销毁,设置该标记,下次循环将线程数减少
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
完整流程回顾:
1.4 Executors工具
以上构造函数比较多,为了方便使用,提供了一个Executors工具类
1)newCachedThreadPool() : 弹性线程数
2)newFixedThreadPool(int nThreads) : 固定线程数
3)newSingleThreadExecutor() : 单一线程数
4)newScheduledThreadPool(int corePoolSize) : 可调度,常用于定时
2 锁
2.1 概述
锁是一种互斥的机制,在多线程环境中实现对资源的协调与控制,凡是有资源被多线程共享,涉及到你改我改的情况就要考虑锁的加持。
- 糟糕的实现
public class BadCounter {
private static int i = 0;
public int getI() {
return i;
}
public void inc() {
int j = getI();
try {
Thread.sleep(1000);
j++;
} catch (InterruptedException e) {
e.printStackTrace();
}
i = j;
}
public static void main(String[] args) throws InterruptedException {
BadCounter badCounter = new BadCounter();
for (int j = 0; j < 10; j++) {
new Thread(()->{
badCounter.inc();
}).start();
}
Thread.sleep(3000);
// 理论上是10,没有得到
System.out.println(i);
}
}
2.2 实现方式
想要得到正确结果的方式:
- synchronized
//加synchronized,再测试
public synchronized void inc()
- Lock
//换lock方式测试
Lock lock = new ReentrantLock();
public void inc() {
lock.lock();
//…
lock.unlock();
}
以上两种方式加锁均能实现正确计数,但是性能存在问题。
2.3 锁的分类及详解
1)乐观锁/悲观锁
乐观锁认为每次读取数据的时候总是认为没人动过,所以不去加锁。但是在更新时会去对比一下原来的值,看有没有被人更改过。使用于读多写少的场景。
java中的atomic包属于乐观锁实现,即CAS。
悲观锁在每次读取数据时都会认为其他人会修改数据,所以读取数据的时候也加锁,这样别人想拿的时候就会阻塞,直到这个线程释放锁,这就影响了并发性能。适合写操作比较多的场景。
synchronized就是悲观锁
2)独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有,而共享锁是指该锁可被多个线程所持有。
ReentrantLock,独享锁
ReentrantReadWriteLock,read共享,write独享
- 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
- 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
3)分段锁
ConcurrentHashMap使用Segment(分段锁)技术,将数据分成一段一段的存储,Segment数组的意义就是将一个大的table分割成多个小的table来进行加锁,Segment数组中每一个元素一把锁,每一个Segment元素存储的是HashEntry数组+链表,这个和HashMap的数据存储结构一样。当访问其中一个段数据被某个线程加锁的时候,其他段的数据也能被其他线程访问,这就使得ConcurrentHashMap不仅保证了线程安全,而且提高了性能。
但是这也引来一个负面影响:ConcurrentHashMap 定位一个元素的过程需要进行两次Hash操作,第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表。所以 Hash 的过程比普通的HashMap 要长。
备注:JDK1.8ConcurrentHashMap中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized来保证并发安全性。
4)可重入锁
可重入锁指的获取到锁后,如果同步块内需要再次获取同一把锁的时候,直接放行,而不是等待。其意义在于防止死锁。前面使用的synchronized 和ReentrantLock 都是可重入锁。
实现原理实现是通过为每个锁关联一个请求计数器和一个占有它的线程。如果同一个线程再次请求这个
锁,计数器将递增,线程退出同步块,计数器值将递减。直到计数器为0锁被释放。
场景见于父类和子类的锁的重入(调super方法),以及多个加锁方法的嵌套调用。
案例一:父子可重入
public class ParentLock {
byte[] lock = new byte[0];
public void f1(){
synchronized (lock){
System.out.println("f1 from parent");
}
}
}
public class SonLock extends ParentLock {
public void f1() {
synchronized (super.lock){
super.f1();
System.out.println("f1 from son");
}
}
public static void main(String[] args) {
SonLock lock = new SonLock();
lock.f1();
}
}
案例二:内嵌方法可重入
public class NestedLock {
public synchronized void f1(){
System.out.println("f1");
}
public synchronized void f2(){
f1();
System.out.println("f2");
}
public static void main(String[] args) {
NestedLock lock = new NestedLock();
//可以正常打印 f1,f2
lock.f2();
}
}
- 公平锁/非公平锁
公平锁就是在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,直到按照FIFO的规则从队列中取到自己。
非公平锁与公平锁基本类似,只是在放入队列前先判断当前锁是否被线程持有。如果锁空闲,那么他可以直接抢占,而不需要判断当前队列中是否有等待线程。只有锁被占用的话,才会进入排队。
- 优缺点:
公平锁的优点是等待锁的线程不会饿死,进入队列规规矩矩的排队,迟早会轮到。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁的性能要高于公平锁,因为线程有几率不阻塞直接获得锁。ReentrantLock默认使用非公平锁就是基于性能考量。但是非公平锁的缺点是可能引发队列中的线程始终拿不到锁,一直排队被饿死。 - 编码方式:
很简单,ReentrantLock支持创建公平锁和非公平锁(默认),想要实现公平锁,使用new ReentrantLock(true)。 - 背后原理:
AQS,后面还会详细讲到。AQS中有一个state标识锁的占用情况,一个队列存储等待线程。
state=0表示锁空闲。如果是公平锁,那就看看队列有没有线程在等,有的话不参与竞争乖乖追加到尾
部。如果是非公平锁,那就直接参与竞争,不管队列有没有等待者。
state>0表示有线程占着锁,这时候无论公平与非公平,都直接去排队(想抢也没有)
- 锁升级
java中每个对象都可作为锁,锁有四种级别,按照量级从轻到重分为:无锁、偏向锁、轻量级锁、重量级锁。
A占了锁,B就要阻塞等。但是,在操作系统中,阻塞就要存储当前线程状态,唤醒就要再恢复,这个过程是要消耗时间的…
如果A使用锁的时间远远小于B被阻塞和挂起的执行时间,那么我们将B挂起阻塞就相当的不合算。
于是出现自旋:自旋指的是锁已经被其他线程占用时,当前线程不会被挂起,而是在不停的试图获取锁(可以理解为不停的循环),每循环一次表示一次自旋过程。显然这种操作会消耗CPU时间,但是相比线程下文切换时间要少的时候,自旋划算。
而偏向锁、轻量锁、重量锁就是围绕如何使得cpu的占用更划算而展开的。
注意点:
- 上面几种锁都是JVM自己内部实现,我们不需要干预,但是可以配置jvm参数开启/关闭自旋锁、偏向锁。
- 锁可以升级,但是不能反向降级:偏向锁→轻量级锁→重量级锁
- 无锁争用的时候使用偏向锁,第二个线程到了升级为轻量级锁进行竞争,更多线程时,进入重量级锁阻塞
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 | 若线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块或者同步方法 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 若线程长时间竞争不到锁,自旋会消耗 CPU 性能 | 线程交替执行同步块或者同步方法,追求响应时间,锁占用时间很短,阻塞还不如自旋的场景 |
重量级锁 | 线程竞争不使用自旋,不会消耗 CPU | 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 | 追求吞吐量,锁占用时间较长 |
- 互斥锁/读写锁
- 典型的互斥锁:synchronized,ReentrantLock,读写锁:ReadWriteLock
- 互斥锁属于独享锁,读写锁里的写锁属于独享锁,而读锁属于共享锁
2.4 AQS
1)概念
首先搞清楚,AbstractQuenedSynchronizer抽象的队列式同步器,是一个抽象类,这个类在java.util.concurrent.locks包。除了java自带的synchronized关键字之外,jdk提供的另外一种锁机制。
如果需要自己实现锁的逻辑,可以考虑使用AQS,非常的便捷。
jdk中使用AQS的线程工具类很多,自旋锁、互斥锁、读锁写锁、信号量、通过类继承关系可以轻松查看:
2) 原理
- AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state= 0时表示释放了锁。它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作
- AQS通过内置的FIFO同步队列来完成资源获取线程的排队工作,如果当前线程获取同步状态失败(锁)时,AQS则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
- CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,即不存在队列实例,使用前后节点和指针来实现关联。
3)实现方式
AQS使用了模板设计模式。只需要实现指定的锁获取方法即可,内部的机制AQS已帮你封装好。
需要子类继承AQS,并实现的方法(protected):
- tryAcquire(int arg):独占式获取同步状态,其他线程需要等待该线程释放同步状态
- tryRelease(int arg):独占式释放同步状态
- tryAcquireShared(int arg):共享式获取同步状态,返回值大于等于0则表示获取成功,否则获取失败
- tryReleaseShared(int arg):共享式释放同步状态
使用时,调用的是父类的方法(public) - acquire(int arg):独占式获取
- release(int arg):独占式释放
- acquireShared(int arg):共享式获取
- releaseShared(int arg):共享式释放
4)场景案例
用AQS自己实现一个锁,允许指定最大数量的线程并行运作。其他排队等候
public class MyLock extends AbstractQueuedSynchronizer {
public MyLock(int count) {
setState(count);
}
@Override
protected int tryAcquireShared(int arg) {
for (; ; ) {
int current = getState();
int newCount = current - arg;
if (newCount < 0 || compareAndSetState(current, newCount)) {
return newCount;
}
}
}
@Override
protected boolean tryReleaseShared(int arg) {
for (; ; ) {
int current = getState();
int newState = current + arg;
if (compareAndSetState(current, newState)) {
return true;
}
}
}
public static void main(String[] args) {
final MyLock lock = new MyLock(3);
for (int i = 0; i < 30; i++) {
new Thread(new Runnable() {
@Override
public void run() {
lock.acquireShared(1);
try {
Thread.sleep(2000);
System.out.println("ok");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.releaseShared(1);
}
}
}).start();
}
}
}
3 原子操作(atomic)
3.1 概念
原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被
中断的一个或一系列操作" 。
3.2 CAS
Compare And Set(或Compare And Swap),翻译过来就是比较并替换,CAS操作包含三个操作数——内存位置(V)、预期原值(A)、新值(B)。从第一视角来看,理解为:我认为位置 V 应该是 A,如果是A,则将 B 放到这个位置;否则,不要更改,只告诉我这个位置现在的值即可。
基于CAS的原子类计数器
public class AtomicCounter {
private static AtomicInteger i = new AtomicInteger(0);
public int get() {
return i.get();
}
public void inc() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
i.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
AtomicCounter counter = new AtomicCounter();
for (int j = 0; j < 10; j++) {
new Thread(()->counter.inc()).start();
}
Thread.sleep(3000);
//同样可以正确输出10
System.out.println(counter.get());
}
}
3.3 atomic
- 基本类型
AtomicBoolean:以原子更新的方式更新boolean;
AtomicInteger:以原子更新的方式更新Integer;
AtomicLong:以原子更新的方式更新Long; - 引用类型
AtomicReference : 原子更新引用类型
AtomicReferenceFieldUpdater :原子更新引用类型的字段
AtomicMarkableReference : 原子更新带有标志位的引用类型 - 数组
AtomicIntegerArray:原子更新整型数组里的元素。
AtomicLongArray:原子更新长整型数组里的元素。
AtomicReferenceArray:原子更新引用类型数组里的元素。 - 字段
AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
·AtomicLongFieldUpdater:原子更新长整型字段的更新器。
·AtomicStampedReference:原子更新带有版本号的引用类型。
4 ThreadLocal
4.1 概念
ThreadLocal类并不是用来解决多线程环境下的共享变量问题,而是用来提供线程内部的共享变量。在多线程环境下,可以保证各个线程之间的变量互相隔离、相互独立。
4.2 使用
ThreadLocal实例一般定义为private static类型的,在一个线程内,该变量共享一份,类似上下文作用,可以用来上下传递信息。
public class ThreadLocalDemo implements Runnable {
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
@Override
public void run() {
for (int i = 0; i < 3; i++) {
threadLocal.set(i);
System.out.println("当前线程:" + Thread.currentThread().getName() + ",value=" + threadLocal.get());
}
}
public static void main(String[] args) {
ThreadLocalDemo demo = new ThreadLocalDemo();
new Thread(demo).start();
new Thread(demo).start();
}
}
4.3 实现原理
ThreadLocalMap是ThreadLocal内部类,由ThreadLocal创建,每个Thread里维护一个ThreadLocal.ThreadLocalMap类型的属性threadLocals。所有的value值其实是存储在ThreadLocalMap中。
1)set方法源码
public void set(T value) {
//取到当前线程
Thread t = Thread.currentThread();
//从当前线程中拿出Map
ThreadLocalMap map = getMap(t);
if (map != null)
//如果非空,说明之前创建过了
//以当前创建的ThreadLocal对象为key,需要存储的值为value,写入Map
//因为每个线程Thread里有自己独自的Map,所以起到了隔离作用
map.set(this, value);
else
//如果没有,那就创建
createMap(t, value);
}
2)get方法源码
public T get() {
Thread t = Thread.currentThread();
//获取到当前线程下的Map
ThreadLocalMap map = getMap(t);
if (map != null) {
//如果非空,根据当前ThreadLocal为key,取出对应的value即可
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
//如果map是空的,往往返回一个初始值,这是一个protect方法
//这就是为什么创建ThreadLocal的时候往往要求实现这个方法
return setInitialValue();
}
3)remove方法
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
//很简单,获取到map后,调用remove移除掉
if (m != null)
m.remove(this);
}
4)内存泄露问题如何解决
在上述的get方法中,Entry类继承了WeakReference,即每个Entry对象都有一个ThreadLocal的弱引用,GC对于弱引用的对象采取积极的内存回收策略,避免无人搭理时发生内存泄露。
5 Fork/Join
5.1 概念
ForkJoin是由JDK1.7后提供多线并发处理框架。ForkJoinPool由Java大师Doug Lea主持编写,处理逻辑
大概分为两步。
1.任务分割:Fork(分岔),先把大的任务分割成足够小的子任务,如果子任务比较大的话还要对子任务进行继续分割。
2.合并结果:join,分割后的子任务被多个线程执行后,再合并结果,得到最终的完整输出。
5.2 组成
- ForkJoinTask:主要提供fork和join两个方法用于任务拆分与合并;多数使用RecursiveAction(无返回值的任务)和RecursiveTask(需要返回值)来实现compute方法。
- ForkJoinPool:调度ForkJoinTask的线程池;
- ForkJoinWorkerThread:Thread的子类,存放于线程池中的工作线程(Worker)
- WorkQueue:任务队列,用于保存任务;
5.3 基本使用
一个典型的例子:计算1-1000的和
public class SumDemo extends RecursiveTask<Integer> {
private static final Integer SEGMENT = 100;
private Integer start;
private Integer end;
public SumDemo(Integer start, Integer end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
Integer middle = (end + start)/2;
if (end - start > SEGMENT) {
SumDemo task1 = new SumDemo(start, middle);
task1.fork();
SumDemo task2 = new SumDemo(middle + 1, end);
task2.fork();
return task2.join() + task1.join();
} else {
System.out.println("start=" + start + ",end=" + end);
Integer sum = 0;
for (Integer i = start; i <= end; i++) {
sum += i;
}
return sum;
}
}
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
ForkJoinTask<Integer> taskFuture = forkJoinPool.submit(new SumDemo(1, 1000));
try {
Integer result = taskFuture.get();
System.out.println("taskFuture.result="+ result);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
5.4 设计思想
- 普通线程池内部有两个重要集合:工作线程集合,和任务队列。
- ForkJoinPool也类似,工作集合里放的是特殊线程ForkJoinWorkerThread,任务队列里放的是特殊任务ForkJoinTask
- 不同之处在于,普通线程池只有一个队列。而ForkJoinPool的工作线程ForkJoinWorkerThread每个线程内都绑定一个双端队列。
- 在fork的时候,也就是任务拆分,将拆分的task会被当前线程放到自己的队列中。
- 队列中的任务被线程执行时,有两种模式,默认是同步模式(asyncMode==false)从队尾取任务(LIFO)
- 窃取:当自己队列中执行完后,工作线程会到其他队列的队首获取任务(FIFO),取到后如果任务再次fork,拆分会被放入当前线程的队列,依次扩张
5.5 注意事项:
使用ForkJoin将相同的计算任务通过多线程执行。但是在使用中需要注意:
- 注意任务切分的粒度,也就是fork的界限。并非越小越好
- 判断要不要使用ForkJoin。任务量不是太大的话,串行可能优于并行。因为多线程会涉及到上下文的切换
6 volatile
6.1 基本概念
回顾Java 内存模型中的可见性、原子性和有序性:
- 可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的
- 原子性,指的是这个操作是原子不可拆分的,不允许别的线程中间插队操作
- 有序性指的是你写的代码的顺序要和最终执行的指令保持一致。因为在Java内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
- volatile要解决的就是可见性和有序性问题。
6.2 使用方式
经典案例:
public class VolatileTest extends Thread {
private static boolean flag = true;
public void run() {
while (flag) ;
System.out.println("finish");
}
public static void main(String[] args) throws Exception {
new VolatileTest().start();
Thread.sleep(2000);
flag = false;
}
}
一直运行,不输出“finish”
给flag加上 volatile则可以输出“finish”
6.3 原理
Java内存模型分为主内存和线程工作内存两大类。
- 主内存:多个线程共享的内存。方法区和堆属于主内存区域。
线程工作内存:每个线程独享的内存。虚拟机栈、本地方法栈、程序计数器属于线程独享的工作内
存。
Java内存模型规定,所有变量都需要存储在主内存中,线程需要时,在自己的工作内存保存变量的副本,线程对变量的所有操作都在工作内存中进行,执行结束后再同步到主内存中去。这里必然会存在时间差,在这个时间差内,该线程对副本的操作,对于其他线程是不见的,从而造成了可见性问题。
但是,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
同时,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,一旦发现过期就会将当前处理器的缓存行设置成无效状态,强制从主内存读取,这就保障了可见性。
而volatile变量,通过内存屏障(JMM课程)可以禁止指令重排。从而实现指令的有序性。
6.4 注意点
volatile不能保证锁的原子性。
7 ConcurrentHashMap
7.1 基本使用
new创建即可:
Map map = new ConcurrentHashMap();
7.2 实现原理
1.8采用的是cas + synchronized 操作,具体看代码:
put操作:
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//计算hash
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) { // 自旋,确保插入成功
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); //表为空的话,初始化表
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {// hash值对应的槽没有元素
//否则,插入元素,看下面的 casTabAt 方法
//cas 在这里!
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f); // 扩容
else {
V oldVal = null;
//其他情况下,加锁保持
//synchronized 在这里!
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// key已存在,则覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 不存在将key插入到链表尾部。
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// f是红黑树时的put操作
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
// 转成红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
//compareAndSetObject,比较并插入,典型CAS操作
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,Node<K,V> c, Node<K,V> v) {
return U.compareAndSetObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
get取值:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
//判断table是不是空的,当前桶上是不是空的
//如果为空,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//找到对应hash槽的第一个node,如果key相等,返回value
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果正在扩容,不影响,继续顺着node找即可
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//其他情况,逐个便利,比对key,找到后返回value
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结:
put过程:
1.根据key的hash值定位到桶的位置
2.如果table为空,先初始化table
3.如果table当前桶内没有node,CAS操作添加元素,成功 则跳出循环,失败则进入下一轮for循环
4.判断是否有其他线程在扩容,有则帮忙扩容,扩容完成再添加元素
5.如果桶的位置有node,遍历该桶的链表或者红黑树,若key已存在,则覆盖,不存在则将key-value插入到链表或红黑树的尾部
get过程:
1.根据key的hash值定位到桶的位置
2.table是否初始化,没有返回null
3.定位的桶是否有头节点,没有返回null
4.是否有其他线程在扩容,有的话调用find方法沿node指针往后查找。扩容与find可以并行,因为node的next指针不会变
5.若没有其他线程在扩容,则遍历桶对应的链表或红黑树,使用equals方法进行比较。key相同则返回value,不存在则返回null
7.3 注意点
1.ConcurrentHashMap线程安全指put和get操作线程安全,如果get后改变value再put回去,这不是一个原子操作,不能保证安全。
2.get操作没有锁,是因为ConcurrentHashMap的属性如table,nextTable,baseCount等使用了volatile修饰,在线程切换时可以保证可见性,使得get到的都是主存中的最新值。
8 并发容器
8.1 清单
1.ConcurrentHashMap
对应:HashMap
目标:代替Hashtable、synchronizedMap,使用最多,前面详细介绍过
原理:JDK7中采用Segment分段锁,JDK8中采用CAS+synchronized
2.CopyOnWriteArrayList
对应:ArrayList
目标:代替Vector、synchronizedList
原理:高并发往往是读多写少的特性,读操作不加锁,而对写操作加Lock独享锁,先复制一份新的集
合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可见性。
查看源码:volatile array,lock加锁,数组复制
3.CopyOnWriteArraySet
对应:HashSet
目标:代替synchronizedSet
原理:与CopyOnWriteArrayList实现原理类似。
4.ConcurrentSkipListMap
对应:TreeMap
目标:代替synchronizedSortedMap(TreeMap)
原理:基于Skip list(跳表)来代替平衡树,按照分层key上下链接指针来实现。
5.ConcurrentSkipListSet
对应:TreeSet
目标:代替synchronizedSortedSet(TreeSet)
原理:内部基于ConcurrentSkipListMap实现,原理一致
6.ConcurrentLinkedQueue
对应:LinkedList
对应:无界线程安全队列
原理:通过队首队尾指针,以及Node类元素的next实现FIFO队列
7.BlockingQueue
对应:Queue
特点:拓展了Queue,增加了可阻塞的插入和获取等操作
原理:通过ReentrantLock实现线程安全,通过Condition实现阻塞和唤醒
实现类:
LinkedBlockingQueue:基于链表实现的可阻塞的FIFO队列
ArrayBlockingQueue:基于数组实现的可阻塞的FIFO队列
PriorityBlockingQueue:按优先级排序的队列