由于JVM运行程序的实体是线程,每个线程创建时JVM都会为其创建一个工作内存(栈空间),工作内存是每个线程的私有数据区域,而java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,各个线程中的工作内存存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程之间的传值必须通过主内存来完成
java实现同步的方式:
1、同步方法
2、同步代码块
3、使用特殊域变量(volatile)实现线程同步
4、使用可重入锁实现线程同步 ReentrantLock
5、使用局部变量实现线程同步 ThreadLocal
6、使用阻塞队列实现线程同步 LinkedBlockingQueue
7、使用原子变量实现线程同步 juc atomic包
一、多线程
1、线程的生命周期
新建(new)—>就绪(Runnable)—>运行(Running)—>阻塞(Blocked)—>死亡(Dead)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u1n1f8P0-1595924390062)(…/img/多线程.png)]
2、你是如何理解多线程的?
-
多线程定义:
多线程是指从硬件或软件上实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多个线程,进而提升整体的处理性能。
-
多线程由来:
因为单个线程的处理能力低下。打个比方,一个人去搬砖与几个人去搬砖,一个人只能同时搬一车,但是几个人可以同时一起搬多个车。
-
多线程实现:
在 Java 中,实现多线程主要是依靠JUC包下的四种方式:
- 直接继承Thread类;
- 实现 Runnable 接口,无返回值; 不可抛异常
- 实现 Callable 接口配合 FutureTask 对象使用,可以返回执行结果,可以抛出异常; Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。
- 使用线程池。如 Executors 工具类。
-
进程和线程:
进程:一个内存中运行的应用程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个线程,进程也是程序的一次执行过程,是系统运行程序的基本单位,系统运行一个程序即一个进程从创建、运行到消亡的过程
线程:线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程至少有一个线程,一个进程中可以有多个线程,这个程序也可以称为多线程程序。
3、多线程编程中的三个核心概念
-
原子性
与数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)在执行时,要么全部成功(生效),要么全都不成功(都不生效)。
如转账问题:包含减钱与加钱两个子操作,要么这两个操作一起成功,要么一起失败。
-
可见性
可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。
可见性问题是由硬件机制引起的:CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程在读取共享变量时,都会将该变量加载进其所在CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非是更新后的数据。
-
顺序性
顺序性指的是,**程序执行的顺序按照代码的先后顺序执行。**在实际处理中,处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码的执行顺序,按照更高效的顺序执行代码。
4、线程之间的通信方式
使用 volatile 关键字,使用Object类的wait() 和 notify() 方法,使用JUC工具类 CountDownLatch,
-
等待通知机制有:
-
wait()
t.wait():让调用此方法的线程进入等待状态,释放CUP并释放持有的锁,只有notify()/notifyAll()可以唤醒。
-
notify()/notifyAll()
notify():唤醒等待队列中的一个线程,使其获得锁进行访问。
notifyAll():唤醒等待队列中等待该对象锁的全部线程,让其竞争去获得锁。
-
join()
t.join()方法阻塞调用此方法的线程(calling thread),直到线程t完成,此线程再继续;通常用于在main()主线程内,等待其它线程完成再结束main()主线程。
-
interrupted():
interrupt():该方法是用于中断线程的,调用该方法的线程的状态将被置为"中断"状态。
注意:**调用interrupt()方法仅仅是在当前线程中打了一个停止的标记,并不是真的停止线程,需要用户自己去监视线程的状态并做处理。**这一方法实际上完成的是,在线程受到阻塞时抛出一个中断信号,这样线程就得以退出阻塞的状态。更确切的说,如果线程被Object.wait, Thread.join和Thread.sleep三种方法之一阻塞,那么,它将接收到一个中断异常(InterruptedException),从而提早地终结被阻塞状态。
interrupted():测试当前线程(当前线程是指运行interrupted()方法的线程)是否已经中断,且清除中断状态。
isInterrupted():测试线程(调用该方法的线程)是否已经中断,不清除中断状态。
interrupted()、isInterrupted()区别:
interrupted 是作用于当前线程,isInterrupted 是作用于调用该方法的线程对象所对应的线程。(线程对象对应的线程不一定是当前运行的线程。例如我们可以在A线程中去调用B线程对象的isInterrupted方法。)
这两个方法最终都会调用同一个方法-----isInterrupted(boolean ClearInterrupted),只不过参数ClearInterrupted固定为一个是true,一个是false;下面是该方法,该方法是一个本地方法。
-
-
并发同步工具有:
- synchronized
- lock
- CountDownLatch
- CyclicBarrier
- Semaphore
5、锁
1、定义
锁是在不同线程竞争资源的情况下用来分配不同线程执行方式的同步控制工具,只有线程获取到锁之后才能访问同步代码,否则需等待其他线程使用结束后释放锁。
- 锁消除:jvm即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除
- 锁粗化:原则上,同步块的作用范围尽量小,但是如果一系列的连续操作对同一个对象反复的加锁和解锁,导致不必要的性能消耗,其实就是增大锁的作用域
2、锁分类
锁主要存在四种状态:无锁、偏向锁、轻量级锁、重量级锁,会随着竞争而逐渐升级
-
无锁
-
偏向锁:目的在于消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能,偏向锁会偏向于第一个获得它的线程,如果接下来执行过程中,该锁没有被其他线程获取,那持有偏向锁的线程将永远不同步
- 当锁第一次被线程获取时候,线程会使用CAS操作吧线程的ID记录在对象的Mark Word中,同时置偏向标志位1,以后线程再进入和退出代码块不需要CAS操作来加锁和解锁,只需简单测试一下Mark Word里是否存储着当前线程的ID,测试成功表示线程已获得锁
-
轻量级锁(自旋锁):是指当前锁是偏向锁的时候,被另外的线程所访问,那么偏向锁就会升级为
轻量级锁
,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0 ),虚拟机首先将在当前线程的栈帧中建立一个名为
锁记录(Lock Record)
的空间,用于存储锁对象目前的 Mark Word 的拷贝,然后拷贝对象头中的 Mark Word 复制到锁记录中。 - 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record里的 owner 指针指向对象的 Mark Word。
- 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为 00 ,表示此对象处于轻量级锁定状态。
- 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为10 ,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为 01 状态,是否为偏向锁为 0 ),虚拟机首先将在当前线程的栈帧中建立一个名为
-
重量级锁:重量级锁也就是通常说 synchronized 的对象锁,锁标识位为10,其中指针指向的是 monitor
对象(也称为管程或监视器锁)的起始地址。
![轻量级锁](https://i-blog.csdnimg.cn/blog_migrate/8d1f8b250d05ff548b189fd00c275191.jpeg)
![img](https://i-blog.csdnimg.cn/blog_migrate/a33cad953be560203a7bb9e170432477.png)
3、synchronized(方法和代码块)
隐式锁,通常和wait(),notify(),notifyAll()一块使用。
原理:在JVM级别实现
- 同步代码块会在生成的字节码中加上monitorenter和monitorexit,任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。
- 同步方法是在Class文件的方法表中将该方法的accessflags字段中的sync标志位设为1,表示该方法是同步方法并调用该方法的对象或该方法所属的Class在jvm的内部对象表示klass作为锁对象
关联使用
- wait:释放占有的对象锁,释放CPU,进入等待队列只能通过notify/notifyAll继续该线程。
- sleep:释放CPU,但是不释放占有的对象锁,可以在sleep结束后自动继续该线程。
- notify:唤醒等待队列中的一个线程,使其获得锁进行访问。
- notifyAll:唤醒等待队列中等待该对象锁的全部线程,让其竞争去获得锁。
synchronized是可重入锁
- 原理:每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁
4、lock
显示锁,拥有synchronize相同的语义,但是添加一些其他特性,如中断锁等候和定时锁等候。可以使用lock代替synchronize,但必须手动加锁和释放锁。通过lock()手动加锁,通过unlock()方法手动释放锁,unlock()方法需要放到finally里保证锁能释放。
-
synchronized与lock的区别
synchronized lock 性能 稍差【竞争激烈时】/差不多【竞争不激烈】 好【竞争激烈时】/差不多【竞争不激烈】 用法 普通同步方法:锁是当前实例对象。静态同步方法,锁是当前类的class对象。同步代码块:锁是括号里的对象。 通过代码实现,需要手动上锁与释放,提供了多样化的同步,如公平锁、有时间限制的同步、可以被中断的同步 原理 monitor是JVM的一个同步工具,synchronized还通过内存指令屏障来保证共享变量的可见性。 使用AQS在代码级别实现,通过Unsafe.park调用操作系统内核进行阻塞。 功能 非公平锁、要么随机唤醒一个线程要么唤醒全部线程 ReentrantLock:可指定公平锁/非公平锁。提供Condition(条件)类,可实现分组唤醒。提供中断等待机制【lockInterruptibly()】 使用总结:
写同步的时候,优先考虑synchronized,如果有特殊需要,再进一步优化。ReentrantLock和Atomic如果用的不好,不仅不能提高性能,还可能带来灾难。
- sync是关键字属于JVM层面 Lock是具体类是api层面的锁
- sync不需要用户手动释放锁执行完成后自动释放对锁的占用,ReentrantLock则需要用户手动释放,否则会出现死锁
- sync不可中断,除非抛出异常或者正常运行完成,ReentrantLock可中断
- sync非公平锁,ReentrantLock两者皆可
- 绑定多个条件condition
- sync没有
- ReentrantLock用来实现分组唤醒需要唤醒的线程们,可以精确唤醒,而不像sync要么随机唤醒,要么全部唤醒。
5、volatile(可见性,有序性)
-
在主内存和工作内存交互时,直接与主内存产生交互【不通过缓存】,进行读写操作,保证数据的内存可见性【实时刷新】;
- 修改volatile变量时JVM会向处理器发送一条lock前缀的指令,会强制将修改后的值刷新的主内存中。
- 修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。
-
禁止 JVM 进行的指令重排序。
- 内存屏障(memory barriers)
- 阻止屏障两侧的指令重排序
- 强制刷新主内存数据,以及让缓存中相应的数据失效
- happens-before:它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性
- 程序顺序原则:一个线程内保证语义的串行性.对于单线程来讲,必须保证重排后的结果与重排前一致。
- volatile规则:volatile变量的写,先发生于后续对这个变量的读.这保证了volatile变量的可见性.
- 监视锁规则:对于一个锁的解锁,先发生于随后对这个锁的加锁. 否则随后的加锁将会失败.
- 传递性:A先于B,B等于C,那么A必然先于C.
- 线程启动规则:Thread对象的start()方法先发生于此线程的其他任意动作。
- 线程终止规则:线程的所有操作都先发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程interrupt()方法的调用先发生于被中断线程的代码检测到中断时事件的操作。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先发生于它的finalize()方法的开始
- 内存屏障(memory barriers)
Synchronized vs Volatile
- **作用:** volatile解决的是变量在多线程间的可见性,而syn解决的是多线程间访问资源的同步性
- **修饰域:** volatile只能修饰变量;synchronized可以修饰方法和代码块
- **原子性:** volatile保证数据可见性,但不保证原子性;而syn可以保证原子性,也可以间接保证可见性,因为会将私有内存和公共内存中的数据做同步
- **性能:** volatile是线程同步的轻量级实现,性能优于synchronized,但synchronized的性能不断被优化提升,实际上表现不差
6、CAS
整个AQS同步组件,atomic原子类操作都是基于CAS实现
解决ABA问题:加版本号
7、锁优化的思路和方法有以下几种:
- 减少锁持有时间
- 减小锁粒度
- 锁分离
- 锁粗化
- 锁消除
可重入锁(递归锁)
可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。对于Java ReentrantLock而言, 其名字是Reentrant Lock即是重新进入锁。对于synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。线程可以进入任何一个它已经拥有的锁同步着的代码块
加锁时通过CAS算法,将对象放在一个双向链表中,每次获得锁时候,看下当前维护的那个线程id和请求的线程id是否一致
自旋锁
尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,
好处减少线程上下文切换的消耗,缺点是循环会消耗CPU
解决自旋时间过长:在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定
独占锁(写锁)/共享锁(读锁)
ReentrantReadWriteLock
- 独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
- 共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
乐观锁/悲观锁
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。悲观锁在Java中的使用,就是利用各种锁。乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
6、并发工具
1、CountDownLatch
-
概念:
- countDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。
- 是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。
-
构造器:
public CountDownLatch(int count)
count
:需要等待的线程数,当count减到0时才会执行。 -
重要方法:
await()
:等待count指定的线程线程数都完成后再执行。await(long timeout, TimeUnit unit)
:等待一段时间,不管其他线程玩不完成都执行。countDown()
:让等待的线程数-1,当一个线程完成任务后调用。
public class TestCountDownLatch {
public static void main(String[] args) {
/**
* CountDownLatch闭锁的执行原理: latch.countDown(); 即每有一个线程完成工作后,
* CountDownLatch= CountDownLatch - 1; 当 CountDownLatch 0时,表示每有其他线程在执行了
*/
final CountDownLatch latch = new CountDownLatch(10);
LatchDemo ld = new LatchDemo(latch);
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
new Thread(ld).start();
}
try {
latch.await(); //让 main 线程执行到此处时,进行闭锁,等待其他线程完成操作后再执行后续代码。
} catch (InterruptedException e) {
e.printStackTrace();
}
long end = System.currentTimeMillis();
// 不用闭锁时,11个线程(1个主线程,10个子线程)同时执行,是无法计算耗时的。
System.out.println("耗时为(ms):" + (end - start));
}
}
class LatchDemo implements Runnable {
private CountDownLatch latch;// 闭锁
public LatchDemo(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
//保证线程安全
synchronized (this) {
try {
// 打印 5W 以内的奇数
for (int i = 0; i < 50000; i++) {
if ((i & 1) 1) {
System.out.println(i);
}
}
} finally {
// latch.countDown();即CountDownLatch = CountDownLatch - 1;使用闭锁时要保证该代码一定执行到。
latch.countDown();
}
}
}
}
2、CyclicBarrier
原理:在CyclicBarrier类的内部有一个计数器,每个线程在到达屏障点的时候都会调用await方法将自己阻塞,此时计数器会减1,当计数器减为0的时候所有因调用await方法而被阻塞的线程将被唤醒
- 回环栅栏:
通过它可以实现让一组线程相互等待至某个状态(barrier)之后再全部同时执行。
- 构造器:
public CyclicBarrier(int parties)
;
public CyclicBarrier(int parties, Runnable barrierAction)
;
parties
:让多少个线程或者任务等待至barrier状态。
barrierAction
:所有线程/任务都到达barrier状态后,需要执行的附加任务。【让最后一个完成任务的线程去执行】
- 重要方法:
await()
:让当前线程等待其他线程都处于barrier状态后再执行。
await(long timeout, TimeUnit unit)
:等待到指定时间,若还有线程未到barrier状态,直接让到达barrier的线程执行后续任务。
public class TestCyclicBarrier {
public static void main(String[] args) {
int N = 4;
CyclicBarrier barrier = new CyclicBarrier(N, new Runnable() {
@Override
public void run() {
System.out.println("当前执行额外任务的线程" + Thread.currentThread().getName());
}
});
for (int i = 0; i < N; i++)
new Writer(barrier).start();
}
static class Writer extends Thread {
private CyclicBarrier cyclicBarrier;
public Writer(CyclicBarrier cyclicBarrier) {
this.cyclicBarrier = cyclicBarrier;
}
@Override
public void run() {
System.out.println("线程" + Thread.currentThread().getName() + "正在写入数据...");
try {
Thread.sleep(5000); // 以睡眠来模拟写入数据操作
System.out.println("线程" + Thread.currentThread().getName() + "写入数据完毕,等待其他线程写入完毕");
cyclicBarrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"所有线程写入完毕,继续处理其他任务...");
}
}
}
3、Semaphore
-
信号量
可以控制同时访问的线程个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。
-
构造器
Semaphore(int permits)
;public Semaphore(int permits, boolean fair)
;permits
:表示许可数目,即同时可以允许多少线程进行访问。fair
:表示是否是公平的,公平即等待时间越久的越先获取许可。 -
重要方法
availablePermits()
: 获取可用的许可数目。
acquire()
【阻塞】: 获取一个许可。若无许可能够获得,则会一直等待,直到获得许可。
acquire(int permits)
【阻塞】: 获取permits个许可。
release()
【阻塞】: 释放一个许可。不要求在释放许可之前必须先获获得许可,但如果没有获取许可,就直接release许可会导致Semaphore允许的同时线程数+1(即便将Semaphore设置成final也会+1)。
release(int permits)
【阻塞】: 释放permits个许可。
boolean tryAcquire()
【非阻塞】: 尝试获取一个许可,若获取成功,则立即返回true,若获取失败,则立即返回false。
boolean tryAcquire(long timeout, TimeUnit unit)
【非阻塞,需要等待 timeout 时间】: 尝试获取一个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false。
tryAcquire(int permits)
【非阻塞】: 尝试获取permits个许可,若获取成功,则立即返回true,若获取失败,则立即返回false。
boolean tryAcquire(int permits, long timeout, TimeUnit unit)
【非阻塞,需要等待 timeout 时间】: 尝试获取permits个许可,若在指定的时间内获取成功,则立即返回true,否则则立即返回false
4、区别
1.countDownLatch是一个计数器,线程完成一个记录一个,计数器递减,只能只用一次
2.CyclicBarrier的计数器更像一个阀门,需要所有线程都到达,然后继续执行,计数器递增,提供reset功能,可以
工具 | 作用 | 可否重用 |
---|---|---|
CountDownLatch | 用于某个线程等待若干个其他线程执行完任务之后,它才执行 | 不可 |
CyclicBarrier | 用于一组线程互相等待至某个状态,然后这一组线程再同时执行 | 可以 |
Semaphore | 可以控制同时访问的线程个数,它一般用于控制对某组资源的访问权限。 | / |
二、线程池
1、优点
-
降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
-
提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
-
提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
创建线程池的方式:Executors工厂方法创建,new ThreadPoolExecutor()自定义创建
2、线程池体系结构
1、线程池的体系结构:
|--java.util.concurrent.Executor://负责线程的使用与调度的根接口
|--**ExecutorService //子接口:线程池的主要接口。
|--ThreadPoolExecutor //线程池的实现类,只有线程池的使用功能,无法进行延迟等任务。
|--ScheduledExecutorService //子接口,继承了ExecutorService:负责线程的调度。
|--ScheduledThreadPoolExecutor //继承了ThreadPoolExecutor类,实现ScheduledExecutorService接口,提供线程池的使用与调度功能。
2、工具类:Executors
ExecutorService newCachedThreadPool();//创建缓存线程池,线程的数量无限制,会根据需求自动更改数量。
ExecutorService newFixedThreadPool(int number);//创建固定大小的线程池
ExecutorService newSingleThreadExecutor();//创建单个线程池。线程池中只有一个线程。
ScheduledThreadPoolExecutor newScheduledThreadPool(); //创建固定大小的线程池,还可以延迟或定时的执行任务。
3、线程池的核心参数ThreadPoolExecutor
- corePoolSize:核心线程数量,线程池中常驻的线程数量。
- maximumPoolSize:线程池允许的最大线程数,非核心线程在超时之后会被清除,受限于
CAPACITY
- workQueue:任务阻塞队列,用于存储等待执行的任务。
- keepAliveTime:线程没有任务执行时可以保持的时间【非核心线程】。
- unit:时间单位
- threadFactory:线程工厂,用来创建线程。
- rejectHandler:当任务队列已满时,拒绝任务提交时的策略:
- AbortPolicy【默认】:丢掉任务,并抛RejectedExecutionException异常。
- DiscardPolicy:直接丢掉任务,不抛异常。
- DiscardOldestPolicy:丢掉最老的任务,然后调用execute立刻执行该任务(新进来的任务)。
- CallerRunsPolicy【推荐】:在调用者的当前线程去执行这个任务。
线程池的最大容量:CAPACITY
中的前三位用作标志位,也就是说工作线程的最大容量为(2^29)-1
。
4、线程池四种模型【Executors提供】
newCachedThreadPool
:创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,当需求增加时,则可以添加新的线程,线程池的规模不存在任何的限制。newFixedThreadPool
:创建一个固定大小的线程池,提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的大小将不再变化。newSingleThreadPool
:创建一个单线程的线程池,它只有一个工作线程来执行任务,可以确保按照任务在队列中的顺序来串行执行,如果这个线程异常结束将创建一个新的线程来执行任务。newScheduledThreadPool
:创建一个固定大小的线程池,并且以延迟或者定时的方式来执行任务,类似于Timer。
4、线程池创建线程的核心逻辑
在创建了线程池后,等待提交过来的任务请求
- 当调用execute()方法添加一个任务请求,线程池会做如下判断:
- 如果正在运行的线程数小于corePoolSize,那么马上会创建线程运行这个任务
- 如果正在运行的线程数大于或者等于corePoolSize,那么会将这个任务放入队列
- 如果这时候队列满了并且正在运行的线程数量还小于maximumPoolSize,那么还是要创建非核心线程运行这个任务
- 如果队列满了并且线程数大于或者等于maximumPoolSize,那么会启动饱和拒绝策略来执行
- 当一个线程完成时,它会从队列中取下一个任务来执行
- 当一个线程无事可做,且超过一定的时间(keepAliveTime)时,线程池会判断:
- 如果当前运行的线程数大于corePoolSize,那么这个线程会停掉
- 所以线程池的所有任务完成后,它最终会收缩到corePoolSize的大小
5、线程池源码
线程池的两种提交任务的方式execute和submit
-
execute提交的是Runnable类型的任务,而submit提交的是Callable或者Runnable类型的任务
-
execute的提交没有返回值,而submit的提交会返回一个Future类型的对象
-
execute提交的时候,如果有异常,就会直接抛出异常,而submit在遇到异常的时候,通常不会立马抛出异常,而是会将异常暂时存储起来,等待你调用Future.get()方法的时候,才会抛出异常
1 public <T> Future<T> submit(Callable<T> task) { 2 if (task == null) throw new NullPointerException(); 3 RunnableFuture<T> ftask = newTaskFor(task); 4 execute(ftask); 5 return ftask; 6 }
提交任务还是执行
execute()
方法,只是task
被包装成了FutureTask
,也就是在excute()
中启动线程后会执行FutureTask.run()
方法。submit提交之后,都是将要执行的任务包装为FutureTask来提交,使用者可以通过FutureTask来拿到任务的执行状态和执行最终的结果,最终调用的都是execute方法,其实对于线程池来说,它并不关心你是哪种方式提交的,因为任务的状态是由FutureTask自己维护的,对线程池透明。
6、队列阻塞策略
如果线程池使用无界阻塞队列会怎么样
调用超时,队列会变得越来越大,此时会导致内存飙升起来,而且还可能导致OOM
如果线程池队列满了会发生什么
如果max没有限制,可以无限制不停的创建额外的线程出来,一台机器上有几千个线程,甚至几万个,每个线程都有字的栈内存,占用一定的内存资源,会导致内存资源耗尽,系统也会崩溃。即时系统没有崩溃,也会导致机器的CPU负载过高
-
直接提交(如:SynchronousQueue)。
工作队列的默认选项是
SynchronousQueue
,它将任务直接提交给线程而不保持它们。在此,如果不存在可用于立即运行任务的线程,则试图把任务加入队列将失败,因此会构造一个新的线程。此策略可以避免在处理可能具有内部依赖性的请求集时出现锁。直接提交通常要求无界maximumPoolSizes
以避免拒绝新提交的任务。当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。 -
无界队列(如:LinkedBlockingQueue)。
使用无界队列(如不具有预定义容量的 LinkedBlockingQueue)将导致在所有 corePoolSize
线程都忙时新任务在队列中等待。这样,创建的线程就不会超过 corePoolSize。(因此,maximumPoolSize
的值也就无效了。)当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列 。 除非系统资源耗尽,否则无界的任务队列不存在任务入队失败的情况。
-
有界队列(如 ArrayBlockingQueue)。
使用有限的 maximumPoolSizes 时,有界队列有助于防止资源耗尽,但是可能较难调整和控制。队列大小和最大池大小可能需要相互折衷:使用大型队列和小型池可以最大限度地降低 CPU 使用率、操作系统资源和上下文切换开销,但是可能导致人工降低吞吐量。如果任务频繁阻塞(例如,如果它们是 I/O 边界),则系统可能为超过您许可的更多线程安排时间。使用小型队列通常要求较大的池大小,CPU使用率较高,但是可能遇到不可接受的调度开销,这样也会降低吞吐量。
三、死锁
多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉将无法推动下去。如果系统资源充足,进程的资源请求都会得到满足,死锁出现的可能性很低,否则会因争夺资源而陷入死锁
死锁的四个必要条件:
(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
1、原因
- 系统资源不足
- 进程运行推进顺序不合适
- 资源分配不当
2、查看死锁
Java Visual VM 图形化监控工具
- jps定位进程号
- jstack找到死锁查看
- jconsole可视化工具分析
3、解决死锁
- 预防死锁:破坏四个必要条件中的一个或多个来预防死锁
- 避免死锁:在资源动态分配的过程中,用某种方式防止系统进入不安全的状态。
- 银行家算法:系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配。这是一种保证系统不进入死锁状态的动态策略。
- 检测死锁:运行时产生死锁,及时发现思索,将程序解脱出来。
- 解除死锁:发生死锁后,撤销进程,回收资源,分配给正在阻塞状态的进程。
四、ThreadLocal
1、解决问题(解决数据库连接、Session管理)
-
并发问题:使用
ThreadLocal
代替Synchronized
来保证线程安全,同步机制采用空间换时间 -> 仅仅先提供一份变量,各个线程轮流访问,ThreadLocal
每个线程都持有一份变量,访问时互不影响。 -
数据存储问题:
ThreadLocal
为变量在每个线程中创建了一个副本,所以每个线程可以访问自己内部的副本变量。
2、底层实现
-
每个Thread维护着一个ThreadLocalMap的引用
-
ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
-
调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
-
调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
-
ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。
而
ThreadLocalMap
的实现原理跟HashMap
差不多,内部有一个Entry
数组,一个Entry
通常至少包括key,value
, 特殊的是这个Entry继承了WeakReference
也就是说它是弱引用的所以可能会有 内存泄露 的情况。ThreadLocal
负责管理ThreadLocalMap
,包括插入,删除 等等
jdk8优化原因:
- 这样设计之后每个Map存储的Entry数量就会变小,因为之前存储数量是由
Thread
的数量决定的,现在是由ThreadLocal
的数量决定的 - 当
Thread
销毁之后,对应的ThreadLocalMap
也会随之销毁,减少内存的占用
3、内部类ThreadLocalMap
核心方法
getEntry(ThreadLocal<?> key)
- set(ThreadLocal> key, Object value)`
ThreadLocalMap的set()则是采用开放定址法,开放定址法就是不会有链式的结构,如果冲突了,以当前位置为基准再找一个判断,直到找到一个空的地址。set()操作除了存储元素外,还有一个很重要的作用,就是replaceStaleEntry()和cleanSomeSlots(),这两个方法可以清除掉key == null 的实例,防止内存泄漏。
get()方法有一个重要的地方当key == null时,调用了expungeStaleEntry()方法,该方法用于处理key == null,有利于GC回收,能够有效地避免内存泄漏。
4、线程之间如何传递 ThreadLocal
对象
Threadlocal
的子类 InheritableThreadLocal
5、ThreadLocal
和synchronized
的区别?
synchronized关键字采用了以“时间换空间”的方式,提供一份变量,让不同的线程排队访问。而ThreadLocal采用了“以空间换时间”的方式,为每一个线程都提供一份变量的副本,从而实现同时访问而互不影响。
https://www.jianshu.com/p/e200e96a41a0
6、ThreadLocal为什么会内存泄漏
ThreadLocalMap的key为ThreadLocal实例,他是一个弱引用,我们知道弱引用有利于GC的回收,当key == null时,GC就会回收这部分空间,但value不一定能被回收,因为他和Current Thread之间还存在一个强引用的关系。由于这个强引用的关系,会导致value无法回收,如果线程对象不消除这个强引用的关系,就可能会出现内存泄漏。有些时候,我们调用ThreadLocalMap的remove()方法进行显式处理。
7、java常用的线程调度算法
抢占式,一个线程用完CPU之后,操作系统会根据线程优先级和线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行
8、单例模式的线程安全性
-
概念:负责创建自己的对象,同时确保只有单个对象被创建
-
饿汉式:线程安全
-
懒汉式:线程不安全
-
双检锁:线程安全
9、为什么spring单例是线程安全
- ThreadLocal
- 单例:无状态的Bean(⽆状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象,不能保存数据,是不变类,是线程安全的。)适合⽤用不变模式,技术就是单例模式,这样可以共享实例,提高性能。
五、AQS
AQS提供了一种实现阻塞锁和一系列依赖FIFO等待队列的同步器的框架
AQS就是基于CLH队列,用volatile修饰共享变量state(就是共享资源,其访问方式有如下三种getState();setState();compareAndSetState()😉,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
AQS 定义了两种资源共享方式:
1.Exclusive:独占,只有一个线程能执行,如ReentrantLock
尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
2.Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
ReadWriteLock
ReadLock和WriteLock方法都是通过调用Sync的方法实现的,所以我们先来分析一下Sync源码:
AQS 的状态state是32位(int 类型)的,辦成两份,读锁用高16位,表示持有读锁的线程数(sharedCount),写锁低16位,表示写锁的重入次数 (exclusiveCount)。状态值为 0 表示锁空闲,sharedCount不为 0 表示分配了读锁,exclusiveCount 不为 0 表示分配了写锁,sharedCount和exclusiveCount 一般不会同时不为 0,只有当线程占用了写锁,该线程可以重入获取读锁,反之不成立。