并发编程
- 一、线程基础
- 二、线程安全
- 三、线程池
- 四、使用场景
一、线程基础
1、进程与线程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。比如打开浏览器,打开idea。
一个进程之内可以分为一到多个线程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。
Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。在 windows中进程是不活动的,只是作为线程的容器。
二者对比:
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
2、并行和并发有什么区别?
单核CPU
下线程实际还是串行执行的。
操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
总结为一句话就是: 微观串行,宏观并行
一般会将这种线程轮流使用CPU的做法称为并发(concurrent)
多核CPU
每个核(core)都可以调度运行线程,这时候线程可以是并行的。
并发(concurrent)是同一时间应对(dealing with)多件事情的能力。
并行(parallel)是同一时间动手做(doing)多件事情的能力。
互不干扰才叫并行。
3、 创建线程的方式
3.1继承Thread类
public class MyThread extends Thread{
@Override
public void run() {
System.out.println(Thread.currentThread().getName() +" MyThread run");
}
public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.setName("线程1");
t1.start();
MyThread t2 = new MyThread();
t2.setName("线程2");
t2.start();
}
}
3.2实现runnable接口
public class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"MyRunnable run");
}
public static void main(String[] args) {
MyRunnable mr = new MyRunnable();
new Thread(mr, "线程1").start();
new Thread(mr, "线程2").start();
}
}
3.3实现Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName() +".....callable");
return Thread.currentThread().getName() +"....OK";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
MyCallable mc = new MyCallable();
FutureTask<String> ft = new FutureTask<>(mc);
new Thread(ft, "线程1").start();
System.out.println(ft.get());//获取返回值
}
}
3.4线程池创建线程
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName() +" MyExecutors run");
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
for (int i = 0; i < 10; i++) {
executorService.submit(new MyExecutors());
}
executorService.shutdown();//关闭线程池
}
}
3.5CompletableFuture类(基于ForkJoin线程池)
public class completableFuture {
public static void main(String[] args) throws Exception {
CompletableFuture.supplyAsync(()-> {
System.out.println(Thread.currentThread().getName()+"CompletableFuture");
return "CompletableFuture";
});
Thread.sleep(1000);
}
//执行结果:ForkJoinPool.commonPool-worker-25CompletableFuture
}
3.6Timer定时器类
public class timerTest {
//本质还是实现了Runnable接口
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("定时器线程");
}
}, 0, 1000);
}
}
3.7线程组
public class groupTest {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("groupName");
new Thread(group, ()->{
System.out.println("T1......");
}, "T1").start();
new Thread(group, new Runnable() {
@Override
public void run() {
System.out.println("T2......");
}
}, "T2").start();
}
}
总结: Java中创建线程的方式有很多种,在《Java技术卷》和《Java编程思想》中提供了实现Runnable、Callable接口、继承Thread类、创建线程池这四种常见方式,我们还可以通过ForkJoin线程池、CompletableFuture类、Timer定时器类、parallelStream并行流、匿名内部类或Lambda表达式等多种方式去实现,但这些都不是真正意义上的创建线程,严格意义上,Java创建线程的方式只有一种那就是通过new Thread().start()创建,Runnable、Callable接口只是重写了线程的线程体,用来确定我们线程需要执行的内容。
4、 runnable 和 callable 有什么区别?
- Runnable 接口run方法没有返回值;Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
- Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
- Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛。
5、 线程的 run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。(本质是方法)
6、线程包括哪些状态,状态之间是如何变化的?
6.1新建
当一个线程对象被创建,但还未调用 start 方法时处于新建状态,
此时未与操作系统底层线程关联。
6.2可运行
调用了 start 方法,就会由新建进入可运行,
此时与底层线程关联,由操作系统调度执行。
6.3终结
线程内代码已经执行完毕,由可运行进入终结,
此时会取消与底层线程关联
6.4阻塞
当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用cpu 时间。
当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态。
6.5等待
当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间。
当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态。
6.6有时限等待
当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu时间。
当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁,如果等待超时 也会从有时限等待状态恢复为可运行状态,并重新去竞争锁。
还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态。
7、如何保证线程按顺序执行
在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的join()方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。
代码举例:
为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。
public class joinTest {
public static void main(String[] args) {
//t1
Thread t1 = new Thread(() -> {
System.out.println("t1");
});
//t2
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;
//t3
Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;
// 启动线程
t1.start();
t2.start();
t3.start();
}
}
8、 notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
public class waitNotify {
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + "...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "...被唤醒");
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (lock) {
System.out.println(Thread.currentThread().getName() + "...wating...");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "...被唤醒");
}
},"t2");
t1.start();
t2.start();
Thread.sleep(2000);
synchronized (lock){
System.out.println(Thread.currentThread().getName()+"...notifyAll()");
// lock.notifyAll();
lock.notify();
}
}
}
notify随机唤醒一个线程
notifyAll唤醒所有
9、在 java 中 wait 和 sleep 方法的不同?
- 共同点
wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态 - 不同点
1. 方法归属不同
sleep(long) 是 Thread 的静态方法。
而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有。
2.醒来时机不同
执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来,
wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
它们都可以被打断唤醒
3.锁特性不同(重点)
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃
cpu,但你们还可以用)
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃
cpu,你们也用不了)。
10、如何停止一个正在运行的线程?
有三种方式可以停止线程
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止(使用布尔标记)
- 使用stop方法强行终止(不推荐,方法已作废)
- 使用interrupt方法中断线程
interrupt代码实例:
public static void main(String[] args) throws InterruptedException {
//打断正常运行的线程
Thread t1 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
System.out.println("线程被中断");
break;
}
System.out.println("线程运行中");
}
}, "t1");
t1.start();
Thread.sleep(10);
t1.interrupt();
//打断阻塞的线程 会报错 sleep interrupted
Thread t2 = new Thread(() -> {
while (true) {
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "t2");
t2.start();
t2.interrupt();
}
二、线程安全
1、讲一下synchronized关键字的底层原理
1.1基本使用
如抢票,如果不加锁,就会出现超卖或者一张票卖给多个人的情况。
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。
public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public synchronized void getTicket() {
synchronized (lock) {
if (ticketNum <= 0) {
return;
}
ticketNum--;
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}
1.2底层就是一个Monitor
比如代码:
public class SyncTest {
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
反编译:
monitorenter 上锁开始的地方。
monitorexit 解锁的地方。
其中被monitorenter和monitorexit包围住的指令就是上锁的代码。
有两个monitorexit的原因,第二个monitorexit是为了防止锁住的代码抛异常后不能及时释放锁。
在使用了synchornized代码块时需要指定一个对象,所以synchornized也被称为对象锁。
monitor主要就是跟这个对象产生关联,如下图
Monitor内部具体的存储结构:
Owner: 存储当前获取锁的线程的,只能有一个线程可以获取
EntryList: 关联没有抢到锁的线程,处于Blocked状态的线程
WaitSet: 关联调用了wait方法的线程,处于Waiting状态的线程
具体的流程:
代码进入synchorized代码块,先让lock(对象锁)关联的monitor,然后判断Owner是否有线程持有。
如果没有线程持有,则让当前线程持有,表示该线程获取锁成功。
如果有线程持有,则让当前线程进入entryList进行阻塞,如果Owner持有的线程已经释放了锁,在EntryList中的线程去竞争锁的持有权(非公平)。
如果代码块中调用了wait()方法,则会进去WaitSet中进行等待。
1.3 Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
tatic final Object obj = new Object();
public static void method1() {
synchronized (obj) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized (obj) {
// 同步块 B
}
}
重量级锁:
底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁:
线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性。
比如以上代码,method1的执行线程,执行method2的时候是同一线程的同一个锁对象,这个时候就是轻量级锁,线程只需要每次获取锁时进行CAS并且记录锁,记录数就是重入的次数。
偏向锁:
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
注意:一旦锁发生了竞争,都会升级为重量级锁
2、你谈谈 JMM(Java 内存模型)
JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
如图:
- 所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。所以工作内存是私有的,没有线程安全问题。
- 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
3.1、CAS 你知道吗?
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
例子:
我们还是基于刚才学习过的JMM内存模型进行说明
线程1与线程2都从主内存中获取变量int a = 100,同时放到各个线程的工作内存中,这个时候线程A对a进行加一操作,并且提交给主内存,这时候会进行判断复制过来的a和主内存中的a是否相等,相等即修改(CAS),这时候主内存中a等于101,然后线程B进行减一并且提交a给主内存,但是副本100和主内存101不相等说明有人修改过,这个时候就不会修改主内存,只能重新获取主内存a的最新值再次操作(自旋锁操作)。
3.2 CAS 底层实现
CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令
都是native修饰的方法,由系统提供的接口执行,并非java代码实现,一般的思路也都是自旋锁实现。
4、 请谈谈你对 volatile 的理解
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
4.1 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。
public class ForeverLoop {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println("modify stop to true...");
}).start();
foo();
}
static void foo() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:" + i);
}
}
当执行上述代码的时候,发现foo()方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。
原因:
上述代码
在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,JIT就会优化此代码,如下:
while (true) {
i++;
}
当把代码优化成这样子以后,及时 stop 变量改变为了 false 也依然停止不
了循环
解决方案:
- 在程序运行的时候加入vm参数 -Xint 表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)。
- 在修饰 stop 变量的时候加上 volatile ,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:
static volatile boolean stop = false;
4.2 禁止进行指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
public class testReSort {
int x;
int y;
class Result {
int r1;
int r2;
}
public void actor1(){
x=1;
y=1;
}
public void actor2(Result result){
result.r1=y;
result.r2=x;
}
}
在去获取上面的结果的时候,有可能会出现4种情况
情况一:先执行actor2获取结果—>0,0(正常)
情况二:先执行actor1中的第一行代码,然后执行actor2获取结果—>0,1(正常)
情况三:先执行actor1中所有代码,然后执行actor2获取结果—>1,1(正常)
情况四:先执行actor1中第二行代码,然后执行actor2获取结果—>1,0(发生了指令重排序,影响结果)
解决方法:在y变量上添加volatile,禁止指令重排序,则可以解决问题。
写操作加的屏障是阻止上方其它写操作越过屏障排到volatile变量写之下
读操作加的屏障是阻止下方其它读操作越过屏障排到volatile变量读之上
现在我们就可以总结一个volatile使用的小妙招:
写变量让volatile修饰的变量的在代码最后位置
读变量让volatile修饰的变量的在代码最开始位置
5.1、什么是AQS?
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架,它是构建锁或者其他同步组件的基础框架。
5.2 工作机制
在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,大于等于1表示有锁。
提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList。
条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的WaitSet。
- 线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功
- 线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待(思想:CAS)
- FIFO是一个双向队列,head属性表示头结点,tail表示尾结点。
5.3 AQS是公平锁吗,还是非公平锁?
新的线程与队列中的线程共同来抢资源,是非公平锁。
新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁。
比较典型的AQS实现类ReentrantLock,它默认就是非公平锁,新的线程与队列中的线程共同来抢资源。
AQS底层实现参考:AQS设计思想
6、ReentrantLock的实现原理
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者
的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公
平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程
访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:
public ReentrantLock() {
sync = new NonfairSync();
}
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
提供了两个构造方法,不带参数的默认为非公平
如果使用带参数的构造函数,并且传的值为true,则是公平锁
其中NonfairSync和FairSync这两个类父类都是Sync,而Sync的父类是AQS,所以可以得出ReentrantLock底层主要实现就是基于AQS来实现的。
- 线程来抢锁后使用CAS的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功,如果多次重入则state++即可。
- 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
- 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程(再次CAS获取锁)
- 公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
具体详细流程(源于b站博主:晨曦coding):
7、synchronized和Lock有什么区别 ?
语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能。
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态(state)、公平锁、可打断(lock.lockInterruptibly())、可超时(lock.tryLock(2, TimeUnit.SECONDS))、多条件变量(等待队列的await和signal)。
- Lock 有适合不同场景的实现,如 ReentrantLock,ReentrantReadWriteLock。
性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能。
8、ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
JDK1.7底层采用分段的数组+链表实现。
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
JDK1.7数据结构
- 提供了一个segment数组,在初始化ConcurrentHashMap 的时候可以指定数组的长度,默认是16,一旦初始化之后中间不可扩容。
- 在每个segment中都可以挂一个HashEntry数组,数组里面可以存储具体的元素,HashEntry数组是可以扩容的
- 在HashEntry存储的数组中存储的元素,如果发生冲突,则可以挂单向链表。
存储流程
- 先去计算key的hash值,然后确定segment数组下标,再通过hash值确定hashEntry数组中的下标存储数据。
- 在进行操作数据的之前,会先判断当前segment对应下标位置是否有线程进行操作,为了线程安全使用的是ReentrantLock进行加锁,如果获取锁是被会使用CAS自旋锁进行尝试。
JDK1.8中concurrentHashMap
- 在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表
- 采用 CAS + Synchronized来保证并发安全进行实现CAS控制数组节点的添加,synchronized只sycn锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升。
9、导致并发程序出现问题的根本原因是什么?
Java并发编程三大特性:原子性、可见性、有序性
原子性:要么成功要么失败,可使用synchronized和Lock锁。
内存可见性:让一个线程对共享变量的修改对另一个线程可见。可使用volatile修饰即可。
有序性:防止指令重排序问题,上文有详细说明。
三、线程池
1、说一下线程池的核心参数(线程池的执行原理知道嘛)
从有参构造函数说起(7个核心参数)
- corePoolSize 核心线程数目
- maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
工作流程
1,任务在提交的时候,首先判断核心线程数是否已满,如果没有满则直接添加到工作线程执行
2,如果核心线程数满了,则判断阻塞队列是否已满,如果没有满,当前任务存入阻塞队列
3,如果阻塞队列也满了,则判断线程数是否小于最大线程数,如果满足条件,则使用临时线程执行任务
如果核心或临时线程执行完成任务后会检查阻塞队列中是否有需要执行的线程,如果有,则使用非核心线程执行任务
4,如果所有线程都在忙着(核心线程+临时线程),则走拒绝策略
拒绝策略:
1.AbortPolicy:直接抛出异常,默认策略;
2.CallerRunsPolicy:用调用者所在的线程来执行任务;
3.DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
4.DiscardPolicy:直接丢弃任务;
2、 线程池中有哪些常见的阻塞队列
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务。
比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue
1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当
前队列中执行时间最靠前的
4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移
出操作。
ArrayBlockingQueue的LinkedBlockingQueue区别
强制有界(ArrayBlockingQueue):
LinkedBlockingQueue(默认无界):
左边是LinkedBlockingQueue(单链表)加锁的方式,右边是ArrayBlockingQueue(数组)加锁的方式。
LinkedBlockingQueue读和写各有一把锁,性能相对较好。
ArrayBlockingQueue只有一把锁,读和写公用,性能相对于LinkedBlockingQueue差一些。
3 、如何确定核心线程数?
在设置核心线程数之前,需要先熟悉一些执行线程池执行任务的类型
IO密集型任务
一般来说:文件读写、DB读写、网络请求等。
推荐:核心线程数大小设置为2N+1 (N为计算机的CPU核数)
CPU密集型任务
一般来说:计算型代码、Bitmap转换、Gson转换等。
推荐:核心线程数大小设置为N+1 (N为计算机的CPU核数)
查看电脑核数(N):
java代码:
System.out.println(Runtime.getRuntime().availableProcessors());
任务管理器:
① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
② 并发不高、任务执行时间长
IO密集型的任务 --> (CPU核数 * 2 + 1)
计算密集型任务 --> ( CPU核数+1 )
③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,至于线程池的设置,设置参考(2)
4、 线程池的种类有哪些
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就
有四种
4.1 创建使用固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- 核心线程数与最大线程数一样,没有救急线程。
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE。
- 适用场景:适用于任务量已知,相对耗时的任务。
4.2 单线程化的线程池
它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行。
- 核心线程数和最大线程数都是1。
- 阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE。
- 适用场景:适用于按照顺序执行的任务。
4.3可缓存线程池
- 核心线程数为0
- 最大线程数是Integer.MAX_VALUE
- 阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
- 适用场景:适合任务数比较密集,但每个任务执行时间较短的情况
4.4提供了“延迟”和“周期执行”功能
适用场景:有定时和延迟执行的任务
设定多少时间后启动线程
scheduledThreadPool.schedule(new Task(), 5,TimeUnit.SECONDS);
5、 为什么不建议用Executors创建线程池(参考Java开发手册)
6、线程池如何优雅的关停
这里不得不聊聊关停的两个方法shutdown()
和shutdownNow()
6.1 shutdown
源码注释结论:
- 不会再接受新任务的提交(再提交直接报错)
- 在之前提交的任务会被执行下去,不管是在队列还是正在处理的都会执行下去
- 已经shutdown了再次调用,没有任何意义
打个比方: 银行五点下班,五点前进去的顾客,处理完业务之后离开,五点后的顾客不允许进入了,只能明天早点来。
上代码:
/**
* 第6个任务开始及之后的任务都被拒绝了,1~5号任务正常执行。
* 所以 shutdown 方法将线程池状态置为 SHUTDOWN,线程池并不会立即停止,
* 要等正在执行和队列里等待的任务执行完才会停止。
*/
public static void main(String[] args) throws InterruptedException
{
shutdown_Test();
}
private static void shutdown_Test()
{
ExecutorService threadPool = Executors.newSingleThreadExecutor();
//提交10个任务,在第5个任务提交完,准备提交第6个的时候执行shutdown
for (int i = 1; i <=10; i++)
{
System.out.println("第:"+i+" 次提交");
//此处故意不加try...catch块,方便效果演示
threadPool.execute(new Task(i));
//i等于5的时候shutdown,意味着从第6次开始就不能提交新任务
if (i == 5)
{
threadPool.shutdown();
}
}
}
static class Task implements Runnable
{
String name = "";
public Task(int i) {
name = "task-" + i;
}
String getName(){
return name;
}
@Override
public void run()
{
try
{
TimeUnit.SECONDS.sleep(2);
System.out.println("sleep completed, " + getName());
} catch (InterruptedException e) {
e.printStackTrace();
System.out.println("interrupted, " + getName());
return;
}
System.out.println(getName() + " finished");
System.out.println();
}
}
执行结果:
第:1 次提交
第:2 次提交
第:3 次提交
第:4 次提交
第:5 次提交
第:6 次提交
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task JUC.ThreadLocal和ThreadPool.shutdown_Test$Task@1540e19d rejected from java.util.concurrent.ThreadPoolExecutor@677327b6[Shutting down, pool size = 1, active threads = 1, queued tasks = 4, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
at java.util.concurrent.Executors$DelegatedExecutorService.execute(Executors.java:668)
at JUC.ThreadLocal和ThreadPool.shutdown_Test.shutdown_Test(shutdown_Test.java:33)
at JUC.ThreadLocal和ThreadPool.shutdown_Test.main(shutdown_Test.java:23)
sleep completed, task-1
task-1 finished
sleep completed, task-2
task-2 finished
sleep completed, task-3
task-3 finished
sleep completed, task-4
task-4 finished
sleep completed, task-5
task-5 finished
可以看到第六次提交后就报错直接拒绝,然后继续执行为办完的业务(这里用睡眠2s代替业务),前面的for循环是瞬间执行完的。
6.2 shutdownNow
源码注释总结:
- 不会再接受新的任务(同shutdown)
- 尝试停止所有正在执行的任务(本质是interrupt方法)
- 待执行的任务会取消并且返回等待任务的列表
- 该方法返回未执行的任务列表 ,这些等待的任务从队列中清除
说明:它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt() 方法是无法中断当前的线程的。所以,shutdownNow() 并不代表线程池就一定立即就能退出,它也可能必须要等待所有正在执行的任务都执行完成了才能退出。但是大多数时候是能立即退出的。
举例子:银行突然搞演习,导致正在办理业务的,在客厅等待的,在门口想要进来的,都将他们停止,并且赶出来(但是有的可能不配合正在搞业务)
同上的代码只是换成threadPool.shutdownNow();
执行结果
第:1 次提交
第:2 次提交
第:3 次提交
第:4 次提交
第:5 次提交
第:6 次提交
interrupted, task-1
Exception in thread "main" java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at JUC.ThreadLocal和ThreadPool.shutdown_Test$Task.run(shutdown_Test.java:60)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
java.util.concurrent.RejectedExecutionException: Task JUC.ThreadLocal和ThreadPool.shutdown_Test$Task@1540e19d rejected from java.util.concurrent.ThreadPoolExecutor@677327b6[Shutting down, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
at java.util.concurrent.Executors$DelegatedExecutorService.execute(Executors.java:668)
at JUC.ThreadLocal和ThreadPool.shutdown_Test.shutdown_Test(shutdown_Test.java:34)
at JUC.ThreadLocal和ThreadPool.shutdown_Test.main(shutdown_Test.java:24)
可以看到直接停止了
那问题来了如果线程池后面还有业务怎么办呢?
我们可以调用awaitTermination进行判断 在给定的时间内是否执行完成任务,都执行完则返回true,反之false
所以我们可以先使用shutdown()先不让新的任务进来,再给一定的时间执行,还没执行完就直接调用shutdowmnow()方法强制暂停,再给无法暂停的一些时间,如果还是没完成,就报错。
上官网源码
四、使用场景
1、 线程池使用场景CountDownLatch、Future(你们项目哪里用到了多线程)
结合自己的项目答:
1.1案例一(es数据批量导入)
在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出。
整体流程就是通过CountDownLatch+线程池配合去执行
1.2 案例二(数据汇总)
在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?
在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口
(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能。
1.3案例三(异步调用)
在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务。
2、如何控制某个方法允许并发访问线程的数量?
Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,我们可以通过其限制执行的线程数量,达到限流的效果。
当一个线程执行时先通过其方法进行获取许可操作,获取到许可的线程继续执行业务逻辑,当线程执行完成后进行释放许可操作,未获取达到许可的线程进行等待或者直接结束。
Semaphore两个重要的方法
lsemaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)。
lsemaphore.release():释放一个信号量,此时信号量个数+1。
public static void main(String[] args) {
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 3. 获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
}
3、 谈谈你对ThreadLocal的理解
3.1概述
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享。
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的
ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
3.2 ThreadLocal基本使用
public class ThreadLocalSimple {
private static ThreadLocal<String> sThreadLocal = new ThreadLocal<>();
public static void main(String[] args) {
// 1.开启五个线程,同时向 sThreadLocal 中设置不同的值
// 2.随后从 sThreadLocal 中读取设置的数据
for (int i = 0; i < 5; i++) {
Thread thread = new Thread(() -> {
String threadName = Thread.currentThread().getName();
// 将当前线程的名称设置到 sThreadLocal 中
sThreadLocal.set(Thread.currentThread().getName());
System.out.println(threadName + "-> getFromThreadLocal:" + sThreadLocal.get());
});
thread.setName("thread-" + i);
thread.start();
}
}
}
// 输出
thread-2-> getFromThreadLocal:thread-2
thread-4-> getFromThreadLocal:thread-4
thread-3-> getFromThreadLocal:thread-3
thread-1-> getFromThreadLocal:thread-1
thread-0-> getFromThreadLocal:thread-0
从结果来看可以发现,每个线程都能够从同一个对象 sThreadLocal 中正确的获取到设置的内容。
3.3 ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离。
在ThreadLocal中有一个内部类叫做ThreadLocalMap,类似于HashMap。
ThreadLocalMap中有一个属性table数组,这个是真正存储数据的位置。
set方法
get方法/remove方法
3.4 ThreadLocal-内存泄露问题
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
强引用: 最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM(内存泄漏),也不会对其进行回收。
比如:User user=new User()
弱引用: 表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收。
User user = new User();
WeakReference<User> wr = new WeakReference<>(new User());
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本。
在使用ThreadLocal的时候,强烈建议:将 ThreadLocal 对象声明为 static ,让 ThreadLcoal 的生命周期更长,此时就会一直存在强引用;
每次使用完 ThreadLocal 对象,及时的调用 ThreadLocal.remove() 方法清除数据;
3.5 ThreadLocal和线程池
下面我们来模拟一个场景:开启一个容量为三的线程池,代表三个前台,循环十次代表十个客户,并且在这个三个前台办理业务,办理前是0,办理后是1。
class MyData {
//初始化
ThreadLocal<Integer> threadLocalField = ThreadLocal.withInitial(() -> 0);
//执行业务 执行前为0 执行后为1
public void add() {
threadLocalField.set(1 + threadLocalField.get());
}
}
public class ThreadLocalDemoV2 {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
MyData myData = new MyData();
try {
for (int i = 0; i < 10; i++) {
int finali = i;
executorService.submit(() -> {
//一定要在finally中移除 因为线程池复用线程 如果不remove 下次使用还是上一次的值
try {
Integer beforeInt = myData.threadLocalField.get();
myData.add();
Integer afterInt = myData.threadLocalField.get();
System.out.println(Thread.currentThread().getName() + "工作窗口\t" + "受理第: " + finali + " 个顾客," +
"beforeInt: " + beforeInt + "\t" + "afterInt: " + afterInt);
} finally {
myData.threadLocalField.remove();
}
});
}
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
executorService.shutdown();
}
}
}
使用myData.threadLocalField.remove();执行结果
pool-1-thread-3工作窗口 受理第: 2 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-2工作窗口 受理第: 1 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-1工作窗口 受理第: 0 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-2工作窗口 受理第: 4 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-1工作窗口 受理第: 5 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-1工作窗口 受理第: 7 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-1工作窗口 受理第: 8 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-1工作窗口 受理第: 9 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-3工作窗口 受理第: 3 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-2工作窗口 受理第: 6 个顾客,beforeInt: 0 afterInt: 1
不使用myData.threadLocalField.remove();执行结果
pool-1-thread-1工作窗口 受理第: 0 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-2工作窗口 受理第: 1 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-3工作窗口 受理第: 2 个顾客,beforeInt: 0 afterInt: 1
pool-1-thread-2工作窗口 受理第: 4 个顾客,beforeInt: 1 afterInt: 2
pool-1-thread-1工作窗口 受理第: 3 个顾客,beforeInt: 1 afterInt: 2
pool-1-thread-2工作窗口 受理第: 6 个顾客,beforeInt: 2 afterInt: 3
pool-1-thread-3工作窗口 受理第: 5 个顾客,beforeInt: 1 afterInt: 2
pool-1-thread-2工作窗口 受理第: 8 个顾客,beforeInt: 3 afterInt: 4
pool-1-thread-1工作窗口 受理第: 7 个顾客,beforeInt: 2 afterInt: 3
pool-1-thread-3工作窗口 受理第: 9 个顾客,beforeInt: 2 afterInt: 3
可以发现使用了remove()
方法每个客户是互不影响的,如果不使用则会造成上一客户的信息被复用。