进程与线程
进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位
线程是进程的一个实体,一个进程中一般拥有多个线程。
线程和进程的区别
- 进程是操作系统分配资源的最小单元,线程是操作系统调度的最小单元。
- 进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
- 线程没有独立的地址空间,多进程的程序比多线程的程序健壮
- 进程的切换比线程的切换开销大,所以线程上下文的切换比进程上下文切换要快很多。
为什么线程上下文切换比进程上下文切换快?
- 进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置
- 线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作
线程没有独立的地址空间,哪有独属于自己的资源吗?
有,通过ThreadLocal可以存储线程的特有对象,也就是属于当前线程的资源。
进程的通信方式
通常有管道(包括无名管道和命名管道)、消息队列、信号量、共享存储、Socket、Streams等
进程之间常见的通信方式一般有:
- 通过使用套接字Socket来实现不同机器间的进程通信
- 通过映射一段可以被多个进程访问的共享内存来进行通信
- 通过写进程和读进程利用管道进行通信
线程的通信方式
- volatile
- 使用Object类的wait() 和 notify() 方法
- CountDownLatch方式实现
- join + synchronize/Reentrantlock
volatile
volatile有两大特性,一是可见性,二是有序性,禁止指令重排序,其中可见性就是可以让线程之间进行通信。
volatile语义保证线程可见性有两个原则保证
- 所有volatile修饰的变量一旦被某个线程更改,必须立即刷新到主内存
- 所有volatile修饰的变量在使用之前必须重新读取主内存的值
volatile保证可见性原理图
工作内存2能够感知到工作内存1更新a值是靠的总线,工作内存1在将值刷新的主内存时必须经过总线,总线就能告知其他线程有值被改变,那么其他线程就会主动读取主内存的值来更新。
public class VolatileDemo {
private static volatile boolean flag = true;
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (flag){
System.out.println("trun on");
flag = false;
}
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (!flag){
System.out.println("trun off");
flag = true;
}
}
}
}).start();
}
}
如上代码所示,输出结果会在trun on
和trun off
之间切换,这是因为经过了总线,线程之间相互通信,知道flag值的改变。如果去掉了volatile,在线程切换一定次数后就会发现感知不到flag值的变化了。
wait和notify方法
wait和notify方法来实现等待/通知机制.在一个线程内调用该线程锁对象的wait方法,线程将进入等待队列进行等待直到被通知或者被唤醒,完成线程之间的通信交互。
CountDownLatch
写两个线程,线程1添加10个元素到容器中, 线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束
public class WithoutVolatile1 {
private volatile List list=Collections.synchronizedList (new LinkedList <> ());
public void add(Object i){
list.add (i);
}
public int size(){
return list.size ();
}
public static void main(String[] args) {
WithoutVolatile1 w=new WithoutVolatile1();
//为什么要创建两个门闩呢?
//假如你用一个CountDownLatch 的话会导致线程二在执行的同时出现了CPU的上下文切换,导致数据的不一致,可以多试几次就会发现问题
CountDownLatch countDownLatch = new CountDownLatch (1);
CountDownLatch latch = new CountDownLatch (1);
Thread thread = new Thread (() -> {
try {
for (int i = 0; i < 10; i++) {
if (w.size ( ) == 5) {
countDownLatch.countDown ( );
latch.await ();
}
w.add (i);
System.out.println("添加 " + i);
}
} catch (InterruptedException e) {
e.printStackTrace ( );
}
});
Thread thread1 = new Thread (() -> {
try {
if(w.size ()!=5){
countDownLatch.await ( );
}
System.out.println ("监听到了,结束。。。。");
latch.countDown ();
} catch (InterruptedException e) {
e.printStackTrace ( );
}
});
thread.start ();
thread1.start ();
}
}
线程的状态有哪些?
线程的状态包括 新建状态,运行状态,阻塞等待状态和消亡状态。其中阻塞等待状态又分为BLOCKED, WAITING和TIMED_WAITING状态。
new(新建)->ready(可运行)->running(运行中)->blocked(阻塞状态)->waiting(等待状态)->time_waiting(超时等待)->terminated(终止状态)
- 线程创建后处于new状态,调用start()方法后开始运行,处于ready状态,处于ready状态的线程获得了cpu时间片timeslice后就处于running状态
- 线程执行wait()方法之后进入waiting状态,进入等待状态后需要依靠其他线程的通知才能返回到运行状态
- time_waiting状态相当于在等待基础上增加了超时限制,当超时时间达到后线程返回到runnning状态
- 线程调用同步方法时,如果没有获取锁,就进入blocked状态
- 线程执行了run()方法之后进入terminated状态
多线程编程中常用的函数比较
sleep 和 wait 的区别:
- sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
- wait方法:是Object的方法,必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。
join 方法:当前线程调用,则其它线程全部停止,等待当前线程执行完毕,接着执行。
yield 方法:该方法使得线程放弃当前分得的 CPU 时间。但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。
死锁
死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
造成死锁的原因可以概括成三句话:
- 当前线程拥有其他线程需要的资源
- 当前线程等待其他线程已拥有的资源
- 都不放弃自己拥有的资源
死锁的产生必须满足如下四个必要条件:
- 资源互斥:一个资源每次只能被一个线程使用
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:线程已经获得的资源,在未使用完之前,不能强行剥夺
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系
避免死锁的方法
- 固定加锁的顺序(针对锁顺序死锁)
- 开放调用(针对对象之间协作造成的死锁)
- 使用定时锁-->tryLock()
- 如果等待获取锁时间超时,则抛出异常而不是一直等待!
固定加锁的顺序
先看一个例子
// 转账
public static void transferMoney(Account fromAccount,
Account toAccount,
DollarAmount amount)
throws InsufficientFundsException {
// 锁定汇账账户
synchronized (fromAccount) {
// 锁定来账账户
synchronized (toAccount) {
// 判余额是否大于0
if (fromAccount.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
} else {
// 汇账账户减钱
fromAccount.debit(amount);
// 来账账户增钱
toAccount.credit(amount);
}
}
}
}
有可能会发生死锁:
- 如果两个线程同时调用transferMoney()
- 线程A从X账户向Y账户转账
- 线程B从账户Y向账户X转账
- 那么就会发生死锁。
A:transferMoney(myAccount,yourAccount,10);
B:transferMoney(yourAccount,myAccount,20);
问题: 加锁顺序不一致
解决方法::如果所有线程以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁问题!
public class InduceLockOrder {
// 额外的锁、避免两个对象hash值相等的情况(即使很少)
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct,
final Account toAcct,
final DollarAmount amount)
throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
// 得到锁的hash值
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
// 根据hash值来上锁
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {// 根据hash值来上锁
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {// 额外的锁、避免两个对象hash值相等的情况(即使很少)
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
}
得到对应的hash值来固定加锁的顺序,这样我们就不会发生死锁的问题了!
补充知识点:
锁排序法:(必须回答出来的点)
指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁?
通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。
开放调用
在协作对象之间发生死锁的例子中,主要是因为在调用某个方法时就需要持有锁,并且在方法内部也调用了其他带锁的方法!
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用!
注意:同步代码块最好仅被用于保护那些涉及共享状态的操作!
class CooperatingNoDeadlock {
@ThreadSafe
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
boolean reachedDestination;
// 加Taxi内置锁
synchronized (this) {
this.location = location;
reachedDestination = location.equals(destination);
}
// 执行同步代码块后完毕,释放锁
if (reachedDestination)
// 加Dispatcher内置锁
dispatcher.notifyAvailable(this);
}
public synchronized Point getDestination() {
return destination;
}
public synchronized void setDestination(Point destination) {
this.destination = destination;
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
// Dispatcher内置锁
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
// 执行同步代码块后完毕,释放锁
Image image = new Image();
for (Taxi t : copy)
// 加Taix内置锁
image.drawMarker(t.getLocation());
return image;
}
}
class Image {
public void drawMarker(Point p) {
}
}
}
使用定时锁
使用显式Lock锁,在获取锁时使用tryLock()方法。当等待超过时限的时候,tryLock()不会一直等待,而是返回错误信息。
使用tryLock()能够有效避免死锁问题。
死锁检测
JDK提供了两种方式来给我们检测:
- JconsoleJDK自带的图形化界面工具,使用JDK给我们的的工具JConsole
- Jstack是JDK自带的命令行工具,主要用于线程Dump分析。
原子性,可见性与有序性
背景预览
- 原子性:要么执行,要么不执行,主要使用互斥锁Synchronize或者lock来保证操作的原子性;
- 可见性:一个线程对共享变量的修改,另—个线程能够立刻看到。(具体的说:在变量修改后将新值同步回主内存,主要有两种实现方式,一是volatile,被volatile修饰的变量发生修改后会立即刷新到主内存;二是使用Synchronize或者lock,当一个变量unlock之前会将变量的修改刷新到主内存中);
- 有序性:程序执行的顺序按照代码的先后顺序执行。(具体的说:在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序不会影响单线程的执行结果,却会影响多线程并发执行的正确性。主要有两种方式确保有序性:volatile 和 Synchronize 关键字,volatile是通过添加内存屏障的方式来禁止指令重排序,也就是重排序是不能把后面的指令放到内存屏障之前执行;Synchronize是保证同一时刻有且只有一个线程执行同步代码,类似于串联顺序执行代码)。
原子性:
定义:对于涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果。
举例:银行转账流程中,A账户减少了100元,那么B账户就会多100元,这两个动作是一个原子操作。我们不会看到A减少了100元,但是B余额保持不变的中间结果。
原子性的实现方式:
- 利用锁的排他性,保证同一时刻只有一个线程在操作一个共享变量
- 利用CAS(Compare And Swap)保证
- Java语言规范中,保证了除long和double型以外的任何变量的写操作都是原子操作
- Java语言规范中又规定,volatile关键字修饰的变量可以保证其写操作的原子性
关于原子性,你应该注意的地方:
- 原子性针对的是多个线程的共享变量,所以对于局部变量来说不存在共享问题,也就无所谓是否是原子操作
- 单线程环境下讨论是否是原子操作没有意义
- volatile关键字仅仅能保证变量写操作的原子性,不保证复合操作,比如说读写操作的原子性
可见性:
定义:可见性是指一个线程对于共享变量的更新,对于后续访问该变量的线程是否可见的问题。
为了阐述可见性问题,我们先来简单介绍处理器缓存的概念。
现代处理器处理速度远大于主内存的处理速度,所以在主内存和处理器之间加入了寄存器,高速缓存,写缓冲器以及无效化队列等部件来加速内存的读写操作。也就是说,我们的处理器可以和这些部件进行读写操作的交互,这些部件可以称为处理器缓存。
处理器对内存的读写操作,其实仅仅是与处理器缓存进行了交互。一个处理器的缓存上的内容无法被另外一个处理器读取,所以另外一个处理器必须通过缓存一致性协议来读取的其他处理器缓存中的数据,并且同步到自己的处理器缓存中,这样保证了其余处理器对该变量的更新对于另外处理器是可见的。
在单处理器中,为什么也会出现可见性的问题呢?
单处理器中,由于是多线程并发编程,所以会存在线程的上下文切换,线程会将对变量的更新当作上下文存储起来,导致其余线程无法看到该变量的更新。所以单处理器下的多线程并发编程也会出现可见性问题的。
可见性如何保证?
- 当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前的处理器缓存中
- 当前处理器对共享变量更新之后,需要冲刷处理器缓存,使得该更新可以被写入处理器缓存中
有序性:
定义:有序性是指一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程来看是否有序的问题。
重排序:
为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能会对源代码顺序进行一定的调整,导致程序运行顺序与源代码顺序不一致。
重排序是对内存读写操作的一种优化,在单线程环境下不会导致程序的正确性问题,但是多线程环境下可能会影响程序的正确性。
重排序举例:Instance instance = new Instance()都发生了啥?
具体步骤如下所示三步:
- 在堆内存上分配对象的内存空间
- 在堆内存上初始化对象
- 设置instance指向刚分配的内存地址
第二步和第三步可能会发生重排序,导致引用型变量指向了一个不为null但是也不完整的对象。(在多线程下的单例模式中,我们必须通过volatile来禁止指令重排序)
解析:
- 原子性是一组操作要么完全发生,要么没有发生,其余线程不会看到中间过程的存在。注意,原子操作+原子操作不一定还是原子操作。
- 可见性是指一个线程对共享变量的更新对于另外一个线程是否可见的问题。
- 有序性是指一个线程对共享变量的更新在其余线程看起来是按照什么顺序执行的问题。
- 可以这么认为,原子性 + 可见性 -> 有序性
synchronized关键字
synchronized
是Java中的一个关键字,是一个内部锁。它可以使用在方法上和方法块上,表示同步方法和同步代码块。在多线程环境下,同步方法或者同步代码块在同一时刻只允许有一个线程在执行,其余线程都在等待获取锁,也就是实现了整体并发中的局部串行。
内部锁底层实现:
- 进入时,执行monitorenter,将计数器+1,释放锁monitorexit时,计数器-1
- 当一个线程判断到计数器为0时,则当前锁空闲,可以占用;反之,当前线程进入等待状态
synchronized内部锁对原子性的保证:
在第一个线程获取到锁之后,在他执行完之前不允许其他的线程获取锁并操作共享数据,从而保证了程序的原子性。synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块
synchronized内部锁对可见性的保证:
synchronized内部锁通过写线程冲刷处理器缓存和读线程刷新处理器缓存保证可见性。
- 获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。
- 释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。
synchronized内部锁对有序性的保证:
由于原子性和可见性的保证,使得写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即保证了有序性。
注:内部锁可以使用在方法上和代码块上,被内部锁修饰的区域又叫做临界区
公平锁和非公平锁
- 公平调度方式:
按照申请的先后顺序授予资源的独占权。
- 非公平调度方式:
在该策略中,资源的持有线程释放该资源的时候,等待队列中一个线程会被唤醒,而该线程从被唤醒到其继续执行可能需要一段时间。在该段时间内,新来的线程(活跃线程)可以先被授予该资源的独占权。
如果新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续执行前释放相应的资源,从而不影响该被唤醒的线程申请资源。
优缺点分析:
非公平调度策略:
- 优点:吞吐率较高,单位时间内可以为更多的申请者调配资源
- 缺点:资源申请者申请资源所需的时间偏差可能较大,并可能出现线程饥饿的现象
公平调度策略:
- 优点:线程申请资源所需的时间偏差较小;不会出现线程饥饿的现象;适合在资源的持有线程占用资源的时间相对长或者资源的平均申请时间间隔相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下使用;
- 缺点:吞吐率较小
JVM对synchronized内部锁的调度:
JVM对内部锁的调度是一种非公平的调度方式,JVM会给每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次申请锁的机会。被唤醒的线程等待占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放的锁.
volatile关键字
volatile关键字是一个轻量级的锁,可以保证可见性和有序性,但是不保证原子性。
- volatile 可以保证主内存和工作内存直接产生交互,进行读写操作,保证可见性
- volatile 仅能保证变量写操作的原子性,不能保证读写操作的原子性。
- volatile可以禁止指令重排序(通过插入内存屏障),典型案例是在单例模式中使用。
volatile变量的开销:
volatile不会导致线程上下文切换,但是其读取变量的成本较高,因为其每次都需要从高速缓存或者主内存中读取,无法直接从寄存器中读取变量。
volatile在什么情况下可以替代锁?
volatile是一个轻量级的锁,适合多个线程共享一个状态变量,锁适合多个线程共享一组状态变量。可以将多个线程共享的一组状态变量合并成一个对象,用一个volatile变量来引用该对象,从而替代锁。
ReentrantLock和synchronized的区别
- ReentrantLock是显示锁,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度。
- synchronized 内部锁简单,但是不灵活。显示锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显示锁定义了一个tryLock()方法,尝试去获取锁,成功返回true,失败并不会导致其执行的线程被暂停而是直接返回false,即可以避免死锁。
线程池
线程池就是创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程池中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。
ThreadPoolExecutor有七个参数,分别是
- corePoolSize:线程池中的常驻核心线程数
- maximumPoolSize:线程池中能够容纳同时执行的最大线程数,此值必须大于等于1
- keepAliveTimes:多余的空闲线程存活时间。当线程池的数量超过corePoolSize时,当时间达到keepAliveTime时,多余的线程会被销毁直到剩下corePoolSize个线程为止
- unit:keepAliveTime的单位
- workQueue:任务队列,被提交但为执行的任务
- threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程,一般默认即可
- handler:拒绝策略,表示当队列满了,并且工作线程大于等于线程池的最大线程数时,如何拒绝请求执行的runnable的策略
四个拒绝策略
- AbortPolicy(默认):直接抛出RejectExecutionException阻止运行
- CallerRunsPolicy:不会抛出异常,也不会抛弃任务,而是将任务回退给调用者
- discardOldestPolicy:抛弃队列中等待最久的任务,然后把当前任务加入队列中尝试再次提交当前任务
- discardPolicy:默默丢弃掉无法处理的任务,不会抛出异常也不做任何处理
关于最大线程数maximumPoolSize的设置
maximumPoolSize一般设置为本机线程数,查看本机线程数的方法为Runtime.getRuntime().availableProcessors
常见的线程池类型:
newCachedThreadPool( )
- 核心线程池大小为0,最大线程池大小不受限,来一个创建一个线程
- 适合用来执行大量耗时较短且提交频率较高的任务
newFixedThreadPool( )
- 固定大小的线程池
- 当线程池大小达到核心线程池大小,就不会增加也不会减小工作者线程的固定大小的线程池
newSingleThreadExecutor( )
- 便于实现单(多)生产者-消费者模式
从排队策略看线程池工作原理
排队策略
当我们向线程池提交任务的时候,需要遵循一定的排队策略,具体策略如下:
- 如果运行的线程少于corePoolSize,则Executor始终首选添加新的线程,而不进行排队
- 如果运行的线程等于或者多于corePoolSize,则Executor始终首选将请求加入队列,而不是添加新线程
- 如果无法将请求加入队列,即队列已经满了,则创建新的线程,除非创建此线程超出maxinumPoolSize,在这种情况下,任务默认将被拒绝。
线程池工作原理
从阻塞队列开线程池已满会发生什么
常见的阻塞队列
ArrayBlockingQueue:
- 内部使用一个数组作为其存储空间,数组的存储空间是预先分配的
- 优点是 put 和 take操作不会增加GC的负担(因为空间是预先分配的)
- 缺点是 put 和 take操作使用同一个锁,可能导致锁争用,导致较多的上下文切换。
- ArrayBlockingQueue适合在生产者线程和消费者线程之间的并发程序较低的情况下使用。
LinkedBlockingQueue:
- 是一个*队列(其实队列长度是Integer.MAX_VALUE)
- 内部存储空间是一个链表,并且链表节点所需的存储空间是动态分配的
- 优点是 put 和 take 操作使用两个显式锁(putLock和takeLock)
- 缺点是增加了GC的负担,因为空间是动态分配的。
- LinkedBlockingQueue适合在生产者线程和消费者线程之间的并发程序较高的情况下使用。
SynchronousQueue:
- SynchronousQueue可以被看做一种特殊的有界队列。生产者线程生产一个产品之后,会等待消费者线程来取走这个产品,才会接着生产下一个产品,适合在生产者线程和消费者线程之间的处理能力相差不大的情况下使用。
前边介绍newCachedThreadPool时候说,这个线程池来一个线程就创建一个,这是因为其内部队列使用了SynchronousQueue,所以不存在排队。
注意点
- 使用JDK提供的快捷方式创建线程池,比如说newCachedThreadPool会出现一些内存溢出的问题,因为队列可以被塞入很多任务。所以,大多数情况下,我们都应该自定义线程池。
- 线程池提供了一些监控API,可以很方便的监控当前以及塞进队列的任务数以及当前线程池已经完成的任务数等。
CountDownLatch和CyclicBarrier
两个关键字经常放在一起比较和考察
CountDownLatch是一个倒计时协调器,它可以实现一个或者多个线程等待其余线程完成一组特定的操作之后,继续运行。
CountDownLatch的内部实现如下:
- CountDownLatch内部维护一个计数器,CountDownLatch.countDown()每被执行一次都会使计数器值减少1。
- 当计数器不为0时,CountDownLatch.await()方法的调用将会导致执行线程被暂停,这些线程就叫做该CountDownLatch上的等待线程。
- CountDownLatch.countDown()相当于一个通知方法,当计数器值达到0时,唤醒所有等待线程。当然对应还有指定等待时间长度的CountDownLatch.await( long , TimeUnit)方法。
CyclicBarrier是一个栅栏,可以实现多个线程相互等待执行到指定的地点,这时候这些线程会再接着执行,在实际工作中可以用来模拟高并发请求测试。
可以认为是这样的,当我们爬山的时候,到了一个平坦处,前面队伍会稍作休息,等待后边队伍跟上来,当最后一个爬山伙伴也达到该休息地点时,所有人同时开始从该地点出发,继续爬山。
CyclicBarrier的内部实现如下:
- 使用CyclicBarrier实现等待的线程被称为参与方(Party),参与方只需要执行CyclicBarrier.await()就可以实现等待,该栅栏维护了一个显示锁,可以识别出最后一个参与方,当最后一个参与方调用await()方法时,前面等待的参与方都会被唤醒,并且该最后一个参与方也不会被暂停。
- CyclicBarrier内部维护了一个计数器变量count = 参与方的个数,调用await方法可以使得count -1。当判断到是最后一个参与方时,调用singalAll唤醒所有线程。
ThreadLocal
使用ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。
ThreadLocal内部实现机制:
- 每个线程内部都会维护一个类似HashMap的对象,称为ThreadLocalMap,里边会包含若干了Entry(K-V键值对),相应的线程被称为这些Entry的属主线程
- Entry的Key是一个ThreadLocal实例,Value是一个线程特有对象。Entry的作用是为其属主线程建立起一个ThreadLocal实例与一个线程特有对象之间的对应关系
- Entry对Key的引用是弱引用;Entry对Value的引用是强引用。
Atmoic
引入
经典问题:i++是线程安全的吗?
i++操作并不是线程安全的,它是一个复合操作,包含三个步骤:
拷贝i的值到临时变量
临时变量++操作
拷贝回原始变量i
这是一个复合操作,不能保证原子性,所以这不是线程安全的操作。
那么如何实现原子自增等操作呢?
这里就用到了JDK在java.util.concurrent.atomic包下的AtomicInteger等原子类了。AtomicInteger类提供了getAndIncrement和incrementAndGet等原子性的自增自减等操作。Atomic等原子类内部使用了CAS来保证原子性。
后续更新
- 什么是happened-before原则?
- JVM虚拟机对内部锁有哪些优化?
- 如何进行无锁化编程?
- CAS以及如何解决ABA问题?
- AQS(AbstractQueuedSynchronizer)的原理与实现。