java并发文字总结
1.进程是包含线程的,每个进程内部的线程之间是贡献堆和方法资源区的,但是每个线程的程序计数器,虚拟机栈和本地方法栈是不同的.所以在各个线程之间切换的代价是比较小的.
2.java程序天生就是多线程程序.一个 Java 程序的运行是 main 线程和多个其他线程同时运行
3.请简要描述线程与进程的关系,区别及优缺点?
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。
总结: 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
3.1程序计数器为什么是私有的?
程序计数器主要有下面两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
3.2 虚拟机栈和本地方法栈为什么是私有的?
- 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
- 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
为了保证局部变量不被其他线程访问,所以虚拟机栈和本地方法是线程私有的
3.3 简单区别堆和方法区:
堆:是最大的一块内存,主要存放的是所有线程的共享资源和新创建的对象
方法区: 主要用于存放已被加载的类信息,常量,静态变量.
4.为什么要使用多线程
- 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
- 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
再深入到计算机底层来探讨:
- 单核时代: 在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
- 多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
5.说说线程的生命周期和状态?
1.初始状态,线程被创建出来但是没有被start()
2.运行状态: 线程被调用了start()等待运行的状态
3.阻塞状态: 当线程进入 synchronized
方法/块或者调用 wait
后(被 notify
)重新进入 synchronized
方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
4.等待状态:表示该线程需要等待其他线程作出一些特定动作.(主动的,在同步代码之内)进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
5.超时等待状态: 可以在指定的时间后自己返回而不是一直傻傻等待,像第4步,当超时时间结束后,线程将会返回到 RUNNABLE 状态。
6.终止状态; 表示该线程已经运行完毕
6.上下文切换
在线程执行的过程中,会有自己的状态和运行条件,比如程序计数器,栈等信息,当出现如下的情况的时候,线程会从CPU占用的状态退出
- 主动让出CPU,比如调用了sleep,wait
- 时间片用完了,操作系会在时间片结束的时候主动结束线程
- 调用了阻塞类型的系统中断,比如io
- 被终止或者结束运行
前三种会发生线程切换,所以会保存当前线程的上下文,留待线程下次占用CPU得时候恢复现场.
7 什么是线程死锁?如何避免死锁?
1什么是线程死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
上面的例子符合产生死锁的四个必要条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
2.避免死锁
1.破坏请求与保持条件,一次性申请所有资源
2.破坏不不剥夺条件: 占用部分资源的线程进一步申请其他资源时,则释放自己的资源
3.破坏循环等待条件: 依靠按序申请资源来预防.按照某一顺序来申请资源,释放资源的顺序与之相反.
8.sleep() 方法和 wait() 方法对比
sleep()
方法没有释放锁,而wait()
方法释放了锁 。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。为什么这样设计呢?
9.为什么 wait() 方法不定义在 Thread 中?
wait()
是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object
)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object
)而非当前的线程(Thread
)。
类似的问题:为什么 sleep()
方法定义在 Thread
中?
因为 sleep()
是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁
10.可以直接调用 Thread 类的 run 方法吗
new 一个 Thread
,线程进入了新建状态。调用 start()
方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。 但是,直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
总结: 调用 start()
方法方可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行。
11.volatile 关键字
保证变量的可见性,如果变量被声明为volatile那么就告诉jvm这个变量共享,且不稳定,每次使用都要到主存中去读取.volatile只能保证数据的可见性,并不能保证原子性,但是synchronized关键字二者都可以保证.同时volatile关键字还能禁止指令重排序,具体做法是添加内存屏障.
12 乐观锁和悲观锁
悲观锁: 总是假设最坏的情况会发生,所以共享资源只给一个线程使用,其他线程被阻塞,用完后在把资源转让给其他线程.,比如synchronized和ReentrantLock等独占锁.
乐观锁:总是假设最好的情况,认为资源每次被访问的时候是不会出现问题的,所以线程是不必加锁的,可以无需等待,不停的执行,只是在提交修改的时候去验证对应的资源是否被其他的线程修改了.(CAS或者是版本号)
悲观锁:多用于写比较多的情况,避免频繁的写失败和重试影响性能
乐观锁:通常用于写比较少的情况,避免频繁加锁影响性能,大大提升了系统的吞吐量.
12.2 CAS: 比较与交换算法:
用一个预期值和要更新的值比较,如果相等,则更新.
CAS是一个原子操作,底层是依赖于一条CPU原子指令.涉及到三个操作数:
V:要更新的变量值(var) E:预期值(expect) N:拟写入的值(new)
当且仅当V值== E值时间,CAS才会更新值,假设不存在ABA问题
举一个简单的例子 :线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
- i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
- i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
多个线程同时使用一个,只会有一个执行成功,但是其他的失败线程不会被挂起,而是仅告知失败,并且允许再次尝试.
12.3 乐观锁的问题
ABA问题:
如果一个变量V在初次读取的时候获得的值是1,并且在他准备复制的时候检查到的值仍然是1,那么可以说V没被其他线程修改过么,当然是不能的,因为整个的操作是不具备锁的,所以一旦在初次读取到二次确认的中间有其他线程来进行了数值的修改,而后又改为1,那么CAS操作就会误认为V从来没被修改过.
解决: 加版本号或者时间戳 eg:首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
循环时间长,开销大:
CAS经常用到自旋操作进行重试,就是不成功就一直尝试,知道成功的方式,但是如果长时间不成功,就会给CPU带来巨大的执行开销.
只能保证一个共享变量的原子操作
CAS只对单个共享变量有效(可以看做一种特殊的复制的操作/在操作系统中就是这么规定的),当涉及到操纵多个跨共享变量时,CAS无效,但是从JDK1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作,所以我们可以用锁或者AtomincReference把多个共享变量合并成一个共享变量来操作.
13.Synchronized关键字
synchronized关键字在早期版本中属于重量级锁,但是在java 6 之后synchronized引入了大量的优化,比如自旋锁,锁消除,等等技术来减少锁的开销。
13.1 synchronized如何使用
- 1.修饰实例方法
- 2.修饰静态方法
- 3.修饰代码块
- 修饰实例方法:(锁的是当前对象)
synchronized void method() {//业务代码
}
2.修饰静态方法(锁的是类对象):给当前类加锁,会作用于累的所有对象实例
synchronized static void method() {//业务代码
}
静态synchronized和非静态的synchronized方法之间的调用不互斥。如果一个线程 A 调用一个实例对象的非静态 synchronized
方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized
方法,是允许的,不会发生互斥现象,因为访问静态 synchronized
方法占用的锁是当前类的锁,而访问非静态 synchronized
方法占用的锁是当前实例对象锁。
3.修饰代码块(锁指定对象或者类(在synchronized后面括号里锁的东西))
synchronized(this) {//业务代码
}
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
尽量不要使用synchronized(String a)形式,因为jvm中字符串常量池具有缓存功能
13.2 构造方法能否用synchronized修饰?
不能,因为构造方法本身就是线程安全的,不存在同步构造方法的说法。
13.3 synchronized底层原理
对于同步语块,synchronized方法使用的是jvm中的monitorenter 和 monitorexit来获取锁和释放锁的。对于修饰方法的情况,是通过SYN_SYNCHRONIZED表示来指明当前方法是一个同步方法,从而进行相应的调用。不过量这的本质都是对 对象监视器monitor的获取
修饰同步语句块的情况:
public class SynchronizedDemo {public void method() {synchronized (this) {System.out.println("synchronized 代码块");}}
}
修饰方法的情况:
public class SynchronizedDemo2 {public synchronized void method() {System.out.println("synchronized 方法");}
}
在执行monitorenter
时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。释放锁的流程与之相反。
13.4 synchronized优化以及四种锁
在java6之后做的优化,加入了偏向锁,轻量级锁,重量级锁,自旋锁等状态。
锁主要存在四种状态: 无锁状态,偏向级锁状态,轻量级锁状态,重量级锁状态
无锁状态 ---> 偏向锁状态: 当某线程访问一个对象并且获取锁的时候,如果该对象的锁为无锁状态,则将该线程作为偏向锁的持有者,且将锁的状态设置为偏向锁状态,此时如果其他线程访问该对象时间,无需再次竞争锁资源,直接进图临界区执行代码。偏向锁状态的目的是为了优化无锁状态下频繁访问同一个对象的场景,避免无效的锁竞争。
偏向锁状态 --> 轻量级锁状态:当一个线程尝试获取锁的时候如果当前的锁已经是偏向锁,且当前锁并不是被这个线程持有,则需要使用CAS操作,将对象的MARK WORD设置为指向自己的锁记录,那么锁就会升级为轻量级锁。如果 CAS 操作成功,则该线程可以直接进入临界区,否则该线程需要使用自旋等待,或者将对象的锁状态升级为重量级锁状态。
轻量级锁 --> 重量级锁 :如果一个线程获取对象的锁的时候,发现这个锁已经是轻量级锁,并且自旋操作超过了一定的次数,则需要将对象的锁升级为重量级锁。此时线程需要进入阻塞等待状态。
13.5 锁可以升级不可降级
在 Java 中,锁可以升级但不可降级。这是因为锁的级别与锁的状态是相互对应的。锁的级别越高,锁的状态就越严格,锁的竞争开销也越大。因此,当一个线程获取了一个较低级别的锁时,它可以升级为较高级别的锁,但是无法降级为较低级别的锁。
升级锁的操作是为了提高并发性能和代码的可靠性,而不可降级的设计则是为了避免出现死锁和数据不一致等问题。因此,在使用锁的过程中,需要根据实际情况选择适当的锁级别,并尽可能减少锁的升级和降级操作
13.6 synchronized 和volatile区别:
这两者的关系是互补的存在而不是对立的存在
- synchronized可以保证可见性和原子性,而volatile只能保证可见性
- synchronized可以修饰代码块,方法,而volatile只能修饰变量,volatile是轻量级实现,所以效率高于synchronized
- volatile主要解决变量在多个线程之间的可见性问题,而synchronized方法主要解决的是多个线程之间访问资源的同步性。
14. ReetrantLock
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock
的底层就是由 AQS 来实现的。
14.1 公平锁和非公平锁区别:
公平锁: 在一个线程释放了锁之后,剩下的线程会按照先后顺序依次的获取锁,但是这样会增加一个顺序表的维护,性能较差。上下文切换更加频繁。
非公平锁: 释放锁后,后申请的线程可能先获取锁,是随机或者按照优先级排序的,性能更好,但是可能会有线程永远获取不到锁。
14.2 synchronized和reentrantLock区别
1. 两者都是可重入锁,可重入锁: 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获取了某个对象的锁,但是此时这个对象还没有释放锁,那么要想再次获得这个对象的锁是还可以获取的。例子如下:
public class ReentrantLockDemo {public synchronized void method1() {System.out.println("方法1");method2();}public synchronized void method2() {System.out.println("方法2");}
}
由于 synchronized
锁是可重入的,同一个线程在调用method1()
时可以直接获得当前对象的锁,执行 method2()
的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized
是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()
时获取锁失败,会出现死锁问题
2. syn依赖于JVM而ree依赖于API
syn的依赖是基于jvm的,但是我们对于她的优化是不能直接修改的,是并没有直接暴露给我们,是在虚拟机层面实现的优化。
而reentrantLock是基于jdk层面实现的,所以我们可以查看他的源码。
3.reentrantLock增加了一些高级功能
- 可中断等待: 线程在等待过程中可以选择不再等待,而在syn中只要等待就必须等到锁
- 选择性通知:reentrantLock结合condition, newCondition可以实现选择性通知机制。而syn只有wait()结合notify/notifyall()可以实现等待通知功能。
- 可实现公平性锁: syn是只能非公平锁,reen可以在构造参数中指定是公平还是非公平。(默认非公平)
14.3 关于 Condition
接口的补充:
灵活性很好,可以实现多路通知的功能。也就是在一个lock对象中可以创建多个condition实例(监视器对象),线程对象可以注册在指定的condition中,从而有选择性的进行线程通知,在调度上更加灵活。在使用notify/ notify all的时候通知的对象是由jvm决定的,而在condition 结合reentrantLock可以实现选择性通知。这个功能特别重要,syn关键字相当于整个Lock对象中只有一个condition实例,所有线程都会注册在他一个身上,如果执行notifyall() 就会通知所有处于等待的线程,这样会造成很大的效率问题。而condition实现的signalAll()方法只会唤醒在该Conditon实例中的所有等待线程。
15.ReentrantReadWriteLock
是一个实现了readWriteLock的可重入读写锁,实际上就是两把锁,一把读锁(共享锁),一把写锁(独占锁)。由于 ReentrantReadWriteLock
既可以保证多个线程同时读的效率,同时又可以保证有写入操作时的线程安全。因此,在读多写少的情况下,使用 ReentrantReadWriteLock
能够明显提升系统性能。
- 在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。
- 在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。
15.1 读锁为什么不能升级为写锁?
写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。
另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。
16. ThreadLocal
通常情况下我们创建的变量是会被任何一个线程访问并且修改的,但是如果想实现每一个线程都有自己的专属本地变量的话,我们需要创建一个ThreadLoal类,可以将线程和变量的值绑定。我们可以将ThreadLocl比喻成一个存放数据的盒子。盒子中可以存储每个线程的私有变量。
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。可以使用get, set方法来获取设置当前线程锁存储的副本。避免了线程安全的问题。
例子:
import java.text.SimpleDateFormat;
import java.util.Random;public class ThreadLocalExample implements Runnable{// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));public static void main(String[] args) throws InterruptedException {ThreadLocalExample obj = new ThreadLocalExample();for(int i=0 ; i<10; i++){Thread t = new Thread(obj, ""+i);Thread.sleep(new Random().nextInt(1000));t.start();}}@Overridepublic void run() {System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());try {Thread.sleep(new Random().nextInt(1000));} catch (InterruptedException e) {e.printStackTrace();}//formatter pattern is changed here by thread, but it won't reflect to other threadsformatter.set(new SimpleDateFormat());System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());}}
Thread Name= 0 default Formatter = yyyyMMdd HHmm
Thread Name= 0 formatter = yy-M-d ah:mm
Thread Name= 1 default Formatter = yyyyMMdd HHmm
Thread Name= 2 default Formatter = yyyyMMdd HHmm
Thread Name= 1 formatter = yy-M-d ah:mm
Thread Name= 3 default Formatter = yyyyMMdd HHmm
Thread Name= 2 formatter = yy-M-d ah:mm
Thread Name= 4 default Formatter = yyyyMMdd HHmm
Thread Name= 3 formatter = yy-M-d ah:mm
Thread Name= 4 formatter = yy-M-d ah:mm
Thread Name= 5 default Formatter = yyyyMMdd HHmm
Thread Name= 5 formatter = yy-M-d ah:mm
Thread Name= 6 default Formatter = yyyyMMdd HHmm
Thread Name= 6 formatter = yy-M-d ah:mm
Thread Name= 7 default Formatter = yyyyMMdd HHmm
Thread Name= 7 formatter = yy-M-d ah:mm
Thread Name= 8 default Formatter = yyyyMMdd HHmm
Thread Name= 9 default Formatter = yyyyMMdd HHmm
Thread Name= 8 formatter = yy-M-d ah:mm
Thread Name= 9 formatter = yy-M-d ah:mm
从输出中可以看出,虽然 Thread-0
已经改变了 formatter
的值,但 Thread-1
默认格式化值与初始化值相同,其他线程也一样。
16.1 threadLocal原理:
Thread类源码中有两个变量,threadLocals,inheitableThreadLocals, 他们都是ThreaLocalMap类型的变量。
public class Thread implements Runnable {//......//与此线程有关的ThreadLocal值。由ThreadLocal类维护ThreadLocal.ThreadLocalMap threadLocals = null;//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;//......
}
public void set(T value) {//获取当前请求的线程Thread t = Thread.currentThread();//取出 Thread 类内部的 threadLocals 变量(哈希表结构)ThreadLocalMap map = getMap(t);if (map != null)// 将需要存储的值放入到这个哈希表中map.set(this, value);elsecreateMap(t, value);
}
ThreadLocalMap getMap(Thread t) {return t.threadLocals;
}
通过上面的代码我门可以看到,threadLocal中的变量实际上都是存储在ThreadLocalMap中的,并不是在THreadLocal中, ThreadLocal可以理解为只是对ThreadLocalMap的一个封装,传递了变量值。
ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
比如我们在同一个线程中声明了两个 ThreadLocal
对象的话, Thread
内部都是使用仅有的那个ThreadLocalMap
存放数据的,ThreadLocalMap
的 key 就是 ThreadLocal
对象,value 就是 ThreadLocal
对象调用set
方法设置的值。
16.2 ThreadLocal 的内存泄漏问题
弱引用:如果一个对象是只具有弱引用,那么在gc的时候无论是否内存空间不足,都会被gc回收掉。而对于强引用,jvm宁可抛出out of memory的异常,也不愿意去释放这个引用,强引用是Java中默认的引用类型:强引用如下:
String str = new String("Hello World");
软引用,强引用
软引用用于描述一些还有用,但非必需的对象。对于具有软引用的对象,当系统内存不足时,垃圾回收器会根据程序的需要,自动回收这些对象的内存。因此,使用软引用的对象可以更好地支持内存敏感的应用程序。
例如,下面的代码创建了一个字符串对象,并将它赋给一个软引用变量str:
SoftReference<String> str = new SoftReference<String>(new String("Hello World"));
ThreadLocalMap的可以为弱引用,而value为强引用,所以如果ThreadLocal没有被外部强引用的情况下,在gc中会被回收,这就导致了出现key为null 而value不为null的情况出现。当然ThreadLocal也对这种情况进行了自己的处理,在每次set,get,remove之后都会自己检查并且清除key为null的键值对。但是在使用ThreadLocal之后最好手动再清除下。
17.线程池
线程池就是管理一些列线程资源的资源池,当有任务要处理的时候,直接从线程池中获取线程来处理,处理完成之后线程不会立即被销毁,而是会等待下一个任务
17.1 为甚么使用线程池:
- 节约资源,不用反复创建线程,降低线程创建和销毁过程中的造成的消耗。
- 加快相应速度,不用等待线程创建的过程
- 方便管理,线程是稀缺资源,如果无限制的创建,不但会小号系统的资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。
17.2 如何创建线程池:
1. 通过ThreadPoolExecutor构造函数创建(推荐)
2.通过内置的Executor框架的工具类Executors来创建(不推荐)
通过第二种方法创建可以创建多种类型的RhreadPoolExecutor
- FixedThreadPool 该方法返回一固定容量的线程池,线程池中的线程数量始终不变,当有新任务到达的时候,线程池中若有空闲的线程则会立即执行,若没有,则新的任务会被暂存到一个队列中,待有空闲线程的时候再行处理
- SingleThreadExecutor 该方法只会返回一个单线程的线程池,同样的多余的任务被提交则会添加到队列中,按照先入先出的原则进行执行。
- CachedThreadPool: 返回一个根据实际情况调整线程数量的线程池,线程池的线程数量是不确定的,但若有空闲,则可以复用,若所有的线程均在工作,又有新的任务提交,则会创建新的线程处理问题。当所有线程在当前任务结束之后会返回线程池进行复用。
- ScheduledThreadPool 用来在给定的延迟后运行任务或者定期执行任务的线程池
17.3 为什么不推荐使用内置线程池
《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors
去创建,而是通过 ThreadPoolExecutor
构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
使用Executors返回的线程池的弊端如下:
FixedThreadPool和SingleThreadPool: 使用是无边界的LinkedBlockingQueue。任务最大长度为Ineteger.MAX_VALUE,可能导致oom
CachedThreadPool: 虽然使用的是同步队列,但是允许创建的线程的最大数量仍然是Inrteger.MAX_VALUE, 可能OOM
ScheduledThreadPool: 都是使用的无边界队列。
17.4 线程池常见的参数:
/*** 用给定的初始参数创建一个新的ThreadPoolExecutor。*/public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量int maximumPoolSize,//线程池的最大线程数long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间TimeUnit unit,//时间单位BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务) {if (corePoolSize < 0 ||maximumPoolSize <= 0 ||maximumPoolSize < corePoolSize ||keepAliveTime < 0)throw new IllegalArgumentException();if (workQueue == null || threadFactory == null || handler == null)throw new NullPointerException();this.corePoolSize = corePoolSize;this.maximumPoolSize = maximumPoolSize;this.workQueue = workQueue;this.keepAliveTime = unit.toNanos(keepAliveTime);this.threadFactory = threadFactory;this.handler = handler;}
3个最重要的参数:
- corePoolSize任务队列未达到队列容量时,最大可以同时运行的线程的数量。
- maxiumPoolSize:任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量能变为的最大线程数量
- workQueue:新来的任务会判断当前运行的线程的数量是否达到了核心线程数量,如果达到了就进入到workQueue中等待
其他常见参数:
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁;unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。
17.5 线程池处理任务的流程了解吗?
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用
RejectedExecutionHandler.rejectedExecution()
方法。
17.6 线程池的饱和策略
只有当当前运行的线程数量达到了最大线程数量并且队列也满了的时候才会触发饱和
ThreadPoolExecutor.AbortPolicy
: 抛出RejectedExecutionException
来拒绝新任务的处理。ThreadPoolExecutor.CallerRunsPolicy
当线程池无法再添加新任务时,CallerRunsPolicy
策略会将当前任务返回给调用者来执行,即让提交任务的线程自行执行该任务。也就是说,如果线程池无法执行任务,那么当前线程会被占用执行该任务,直到任务执行完成,当前线程才会返回。这种策略的优点是可以避免任务的丢失,因为即使线程池无法执行该任务,也不会将该任务直接丢弃,而是通过让提交任务的线程执行该任务来保证任务的执行。但是,如果线程池中的任务过多或者任务执行时间过长,使用CallerRunsPolicy
策略可能会导致调用者线程的阻塞,从而影响整个应用程序的性能和响应速度。
ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
17.7 线程池常用的阻塞队列有哪些?
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
- 容量为
Integer.MAX_VALUE
的LinkedBlockingQueue
(无界队列):FixedThreadPool
和SingleThreadExector
。由于队列永远不会被放满,因此FixedThreadPool
最多只能创建核心线程数的线程。 SynchronousQueue
(同步队列) :CachedThreadPool
。SynchronousQueue
没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool
的最大线程数是Integer.MAX_VALUE
,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。DelayedWorkQueue
(延迟阻塞队列):ScheduledThreadPool
和SingleThreadScheduledExecutor
。DelayedWorkQueue
的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue
添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达Integer.MAX_VALUE
,所以最多只能创建核心线程数的线程。
17.8 如何给线程池命名
线程池磨人的线程名字都是 pool - 1- thread-n这种形式的,想要自己命名可以使用guava的ThreadFactoryBuiolder
或者自己实现ThreadFactor:
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/*** 线程工厂,它设置线程名称,有利于我们定位问题。*/
public final class NamingThreadFactory implements ThreadFactory {private final AtomicInteger threadNum = new AtomicInteger();private final ThreadFactory delegate;private final String name;/*** 创建一个带名字的线程池生产工厂*/public NamingThreadFactory(ThreadFactory delegate, String name) {this.delegate = delegate;this.name = name; // TODO consider uniquifying this}@Overridepublic Thread newThread(Runnable r) {Thread t = delegate.newThread(r);t.setName(name + " [#" + threadNum.incrementAndGet() + "]");return t;}}
17.9 设定线程池大小
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
有一个简单并且适用面比较广的公式:
- CPU密集型任务:设置为N+1,这种任务消耗的主要是CPU资源,比CPU核心多出来一个主要是为了防止线程偶发的缺页中断,或者其他原因导致的任务暂停而带来的影响。一旦任务暂停就会使得CPU处于空闲状态,在这种情况下多出来的一个线程就可以充分利用CPU的空闲时间。
- IO密集型任务(2N)这类任务主要的时间消耗在IO交互上,而线程在处理io的时间段内不会占用CPU来处理,这时就可以将CPU交出给其他线程使用。所以我们可以多配置一些线程。
如何判断是 CPU 密集任务还是 IO 密集任务?
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上
18.Future
Future
类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future
类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
在 Java 中,Future
类只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
- 取消任务;
- 判断任务是否被取消;
- 判断任务是否已经执行完成;
- 获取任务执行结果。
18.1 Callable 和 Future 有什么关系?
Callable和Future之间的关系可以简单描述为:Callable用于表示一个异步计算任务,而Future用于获取这个任务的结果或者取消这个任务。通常情况下,我们使用Callable来封装一个具体的计算任务,并将这个任务提交到线程池中执行,然后通过Future对象来获取计算结果或者取消这个任务。这种方式可以帮助我们更好地控制计算任务的执行和结果的获取。
18.2 CompletableFuture 类有什么用?
CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
CompletionStage
接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
CompletionStage
接口中的方法比较多,CompletableFuture
的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。