一、多线程概念
1. 进程和线程的区别
-
进程(Process)是操作系统资源分配的基本单位
内存空间 --> 进程拥有独立的代码、数据空间
进程切换开销 --> 较大 -
线程(Thread)是运行/调度的基本单位
内存空间 --> 每个线程有自己的运行栈和程序计数器(分配进程中的资源)
线程切换开销 --> 小注意:
-
一个进程至少有一个线程,程序执行中的内存单元由多个线程共享。
-
区分:进程 VS 程序
进程 --> 动态概念,表示程序的一次执行过程。
程序 --> 静态概念,是指令和数据的有序合集,本身没有任何运行的含义。
-
2. JVM的线程模型
- 操作系统(OS)将JVM看作是普通的应用程序,JVM中模拟的线程和操作系统中的线程对应关系称为线程模型(OS kernel Thread)。
- 其中JVM的线程和操作系统的线程为1:1的关系,JVM将(重量级锁)线程的调度交给操作系统实现,在JVM中暂时没有对应的调度方法。
- 其他语言中存在N:1或者M:N等线程模型,其中golong中就是M:N模型,Go语言中还有协程的概念(了解)。
3. 并发三大特性
-
① 原子性:
- 一个操作不能被打断。要么全部执行完毕,要么不执行。
- Java中具有原子属性的操作:
- Automic包中的所有类
- 除了long和double以外其他基本类型的赋值操作
- 所有引用的赋值操作
-
② 可见性:一个线程对内存中共享变量值的修改,能够及时被其他线程看到。
-
③ 有序性:程序执行顺序按照代码的先后顺序执行。
问题:线程是不是越多越好?
回答:不是;线程的切换时需要消耗资源花费时间的,太过频繁的切换线程反而会降低效率。达到平衡点时性能最大。计算公式如下,实测中一般为压测。
二、创建线程的方法
1. 继承Thread类
-
步骤:自定义类 --> 继承Thread类 --> 重写run()方法 --> 调用start()方法
注意:
1. main()方法也会开启一个线程
2. 方法start()后不一定马上运行,进入就绪状态等待cpu的调度问题:run()和start()的区别
回答:调用start()方法才是开启了并行的多线程。而调用run()方法只是在当前线程中串行执行run()方法中的代码,并不是真正的多线程。
2. 实现Runnable接口
- 步骤:
自定义类 --> 实现Runnable接口 --> 重写run()方法 --> 创建自定义类实例–> 自定义类实例传入Thread --> 调用start()方法 - Runnable相较于Thread优点:
- 避免了Java语言中单继承的局限性
- 更符合面向对象的特点,将线程进行单独的对象封装
- 降低了线程对象和线程任务的耦合性
- 静态代理模式的使用:
- Thread类和Runnable接口实际上是静态代理的一种实现案例,其中角色分别为:
公共接口 --> Runnable接口
Thread --> 代理角色,实现了Runnable接口
MyRunnable(自定义类)–> 实现了Runnable接口 - 将自定义类的实现对象(真实对象)传入Thread(代理角色),由代理角色替其完成多线程启动工作。而Runnable实现类自身只需要关注run()方法体中的代码。
- Thread类和Runnable接口实际上是静态代理的一种实现案例,其中角色分别为:
3. 实现Callable接口
-
步骤:
自定义类 --> 实现Callable接口 --> 重写call()方法(有返回值)–> 创建目标对象 --> 创建执行服务ExecutorService(线程池等方法)–> 提交执行(可获取异步执行结果Future类) --> 关闭服务 -
Callable特点:
- Callable规定的方法是call(),Runnable/Thread规定的方法是run()
- Callable的任务执行后可返回值,而Runnable的任务不能返回值
- call方法可以抛出异常,run方法不可以
- 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
-
Callable接口的两种使用使用方法:
-
首先继承Callable接口并且重写call()方法
-
借助 Future Task/线程池 执行
-
-
Future Task:
-
线程池:
问题:区分Callable和Runnable
回答:
三、线程状态
- 线程共有五大状态:创建 就绪 运行 阻塞 死亡
1. 创建(new):
- 新创建线程的状态(此时并没有运行)
2. 就绪(runnable)
- 调用了start()方法后,线程进入就绪状态,等待CPU的调度
3. 运行(running)
- 就绪状态的线程获取到了CPU的使用权(时间片),执行其中代码
4. 阻塞(block)
- 处于运行状态中的线程,因为某种原因放弃对CPU的使用权,停止执行。阻塞状态结束后会重新进入就绪状态。
- 阻塞又分为三种状态
- 同步阻塞:
线程执行过程中,获取synchronize同步锁失败(锁被占用),JVM将线程放入锁池中,线程进入同步阻塞状态。 - 等待阻塞:(会释放锁)
线程执行过程中,调用了wait()方法,线程进入等待队列。注意线程从等待队列中被notify()唤醒后,如果需要获取锁且失败了,也会进入锁池中。 - 其他阻塞:(不会释放锁)
线程执行过程中,调用线程的sleep()、join()方法或者发出IO请求,线程会进入阻塞状态。
- 同步阻塞:
5. 死亡(dead)
- 线程run()、main()方法结束(栈帧中最底端方法出栈),或者因异常退出run()方法,则该线程结束生命周期。
- 死亡的线程不可再次复生
四、线程同步
1. 线程安全问题:
- 由于同一进程的多个线程共享该进程储存空间,在带来读取便利的同时,也带来了访问冲突的问题。为确保数据在方法中被访问时的正确性,在访问时加入锁机制(synchronized同步)。
- 当一个线程获得对象的排他锁,其他线程必须等待,获取锁的线程在执行完毕后会释放锁(隐式过程),并且唤醒同步阻塞队列中的后继线程。
2. synchronized加锁原理:
(1) synchronized是一把互斥锁
(2) synchronized关键字通过object中类对象头中的mark word加锁
(3) 对象在内存中的结构构成:
- 对象的组成:对象头 + 实例数据 + 对齐填充字节
- 对象头的组成:Mark Word + 类指针 + 数组长度(数组对象才有)
(4)Mark Word内容:
- Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关,如下图所示:
3. 锁自动升级的过程
- 过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁
(1) 当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。
(2) 当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。
(3) 当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。
(4) 当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。
(5) 偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。
(6) 轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。
(7) 自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞(放入操作系统中,等待操作系统的线程调度器调用)
-
注意:
偏向锁并不是直接升级成为轻量锁,当前线程发现偏向锁记录id不是本线程时,会先CAS尝试获取锁。能够获取锁,则表明锁不存在竞争,将上个线程的偏向锁改为当前线程偏向锁。不能够获取锁,才升级成为轻量锁。 -
重量级锁是通过监视器实现的,交给OS托管
4. synchronized的五种用法
-
- 修饰普通方法:
锁住当前实例对象。同一个实例调用会阻塞,不同实例调用不会阻塞
- 修饰普通方法:
-
- 修饰静态方法:
全局锁/类锁,锁住该类所有实例对象。所有调用该方法的实例对象都同步。
- 修饰静态方法:
-
- 同步代码块传参this:
锁住当前实例对象。同一个实例调用会阻塞,不同实例调用不会阻塞。
- 同步代码块传参this:
-
- 同步代码块传参变量对象:
锁住变量对象。同一个属性对象才会实现同步。
- 同步代码块传参变量对象:
-
- 同步代码块传参class对象:
全局锁/类锁,锁住该类所有实例对象。所有调用该方法的实例对象都同步。
- 同步代码块传参class对象:
五、锁分类
1. 公平锁/非公平锁
-
概念:是否按照在队列中的按照在队列中的等待时间/申请锁的顺序来获取锁
- 公平锁:队列中上个线程结束,只唤醒特定的下个线程
- 非公平锁:按照线程优先级决定获得概率,有可能导致优先级反转(优先级低的线程先执行)或者饥饿现象(优先级低的线程一直得不到执行)。实际操作为上一个特定线程结束,唤醒等待队列中的所有线程。
注意:
synchronized是非公平锁,上一个线程执行完毕会唤醒等待队列中的所有线程,竞争CPU的使用权。
2. 可重入锁/不可重入锁
-
概念:一个线程获得当前实例锁,并且进入了A方法,当A方法没有释放锁的时候,是否可以再次进入使用该锁的B方法?
- 可重入锁:在方法A释放锁之前,可以再次进入方法B
- 不可重入锁:方法A释放锁之后,才可进入方法B
注意:
- 广义上的可重入锁指的是可重复可递归调用的锁(也叫递归锁),在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁。
- 可重入锁的实现原理:通过监视器计数器,每获取一次锁 +1,释放一次锁 -1,计数器为0代表该锁完全释放
3. 乐观锁/悲观锁:
- 概念:并不是具体类型的锁,而是看待并发同步的角度
- 乐观锁:
- 也叫无锁/自旋锁
- 乐观锁认为对于同一个数据的并发操作,数据不会发生修改,所以在更新数据的时候,会使用不断进行尝试的方法进行更新(CAS)。
- 悲观锁:
悲观锁认为对同一个数据的并发操作,一定会发生数据修改,因此在对数据进行操作之前一定要加锁,不加锁一定会出现问题(各种加锁应用)
- 乐观锁:
4.独享锁/共享锁:
- Java中的独享锁和共享锁都可以通过AQS接口来实现
- 独享锁 --> 一次只能被一个线程持有,是排他的/互斥的
- 共享锁 --> 一次能被多个线程持有
5. 偏向锁/轻量级锁/重量级锁
- 概念:针对synchronized的三种锁状态
- 偏向锁:已经获取锁的线程再次获取,可跳过竞争过程
- 轻量级锁:偏向锁发生竞争,升级称为轻量级锁,采用自旋的方式获取锁(JVM层)
- 重量级锁:自旋超过一定次数,表示竞争激烈,升级为重量级锁,放入同步阻塞队列(OS层)
6. 死锁
- 概念:多线程相互持有锁,并且等待其他线程释放占有的锁才能继续执行的情况
- 产生条件:
- 互斥条件:一个资源每次只能被一个线程使用
- 请求与保持条件:一个线程因请求锁而阻塞后,对已获取的资源保持不放
- 不剥夺条件:线程已获得的资源,在未使用完之前不可剥夺,只能由线程自己释放
- 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系
- 只要破坏其中任意一个条件,就能避免死锁的发生。
六、JUC
- JUC:指java.util.concurrent包
1. JUC内容:
-
主要包含了atomic和locks两个包
-
其中atomic类使用的是Unsafe类中的各种方法,主要的方法为CompareAndSwap和CompareAndExchange等
-
问题:CAS如何保证引用对象(如数组)的多个共享变量之间的原子性操作?
回答:- 对一个共享变量进行操作时,CAS能保证原子性操作,但是对多个共享变量进行操作时,CAS无法保证原子性。
- Java从JDK1.5开始提供了AutomicReference类来保证引用对象之间的原子性,可以把多个对象放在一个引用对象中来进行CAS操作
2. CAS解析
-
JUC中大部分锁都是乐观锁,通过CAS实现
-
CAS(Compare Ande Swap),是乐观锁和自旋锁的主要实现方式。
- 原理:将本线程处理结果尝试写入主内存时,先进行比较,若主内存中的值与本线程原本读取的值不一致,说明在处理过程中有其他线程对主内存进行了修改。此时本线程结果作废,重新读取主内存中的值重新处理
-
问题:CAS的ABA问题
即进行比较的时候虽然值相同,但是实际上该值被更改过
回答:
1. 加版本号解决:Atomic Stamped Reference类(可以设置容纳度)
2. 记录Boolean:Markable Reference(改一次都不行)
-
问题:CAS的原子性问题
回答:- 源码跟踪:
- 分析:CAS操作在CPU中本身有指令支持——但是cmpxchg不保证原子性,在此过程中会遇到别的线程同步进行的冲突的问题(例如在compare过程中其他线程修改了内存中的对应值)
- 最终保证原子性方案:LOCK_IF_MP cmpxchg == lock cmpxchg
(MP表示multiple process,即多线程。在compare and exchange指令之前加锁,可以保证该操作的原子性)
该指令优先锁定cache line(缓存行),其次锁定北桥信号
- 源码跟踪:
注意:单线程情况下不用加前缀指令lock,因为时间片的切换模式无法打断
问题:CAS是不是一定比悲观锁效率高?什么时候用悲观锁,什么时候用CAS
回答:
- 总结:能用synchronized解决问题的情况,尽量用synchronized
- 分析:
- CAS操作是自旋的,会消耗CPU。当出现线程执行时间很大的情况,不断的自旋会消耗CPU资源,效率反而降低。
- 悲观锁是将线程放入等待队列,锁释放之后再去等待队列中激活线程,该操作不消耗CPU资源
七、Lock
1. 概念:(接口)
-
- 从JDK5.0开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用lock对象充当。
- locks包来自于JUC。包中的锁大多实现了Lock接口。Lock接口是控制多个线程对共享资源进行访问的工具。
- ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,比较常用
2. Lcok 比较 synchronized
-
- Lock --> 显示加锁/释放锁
synchronized --> 隐式锁,出作用域自动释放 - Lock --> 代码块锁
synchronized --> 代码块锁、方法锁 - 使用Lock锁,JVM将花费较少时间来调度线程,性能更好,扩展性更好
- Lock可以使用Condition进行分区(加入不同的队列),阻塞和唤醒操作比较灵活。而synchronized不行。(例如在消费者生产者模型中,synchronized在唤醒线程时无法区分消费者/生产者)
- Lock --> 显示加锁/释放锁
-
总结:整体上来说Lock是synchronized的扩展版。Lock提供了无条件的、可轮询的(tryLock方法)、定时的(带参tryLock方法)、可中断的(lockInterruptibly)、可多等待队列的(newCondition方法)锁操作。而且Lock实现类基本都支持非公平锁(默认)和公平锁,synchronized只支持非公平锁(更高效)。
八、AQS
1. 概念:
- AQS --> Abstract Queued Synchronized,抽象队列同步器
2. 框架:共享资源(state)+ 等待队列(CLH)
-
AQS的核心思想是:
- 如果被请求的共享资源(volatile修饰)空闲,则将当前请求资源线程设置成为有效的工作线程,并将共享资源设置为锁定状态。
- 如果被请求的共享资源被占用,那么就需要一套线程等待以及被唤醒时锁分配的机制。这个机制AQS是用CLH队列实现的,即将暂时获取不到锁的线程加入到队列中。
-
CLH队列是一个虚拟双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。AQS是将每一条请求共享的线程封装成CLH锁队列的一个节点(Node),来实现节点的分配
3. 资源共享方式:
- AQS定义了两种资源共享的方式
- 独占(Exclusive),只有一个线程能够执行。state初始化为0,若锁是可重入的(如ReentrantLock),每重入一次state+1,每释放一次state-1。
- 共享 (Share),可多个线程执行。state初始化为N,N为共享线程个数。每有一个线程执行state-1,直到state为0停止共享。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0LpzbCyM-1624414081374)(http://39.105.86.48:8090/upload/2021/06/image-b8dd2ef4dd144b53b101e914d2f3ae7f.png)]
4. 读写锁解析
-
读写锁 --> Reentrant ReadWriteLock
-
读锁和写锁的主体都是Sync,实现了AQS接口,状态变量分高低位标识两个锁
-
写锁加锁源码:
- 首先获取当前锁个数c,然后通过c进一步得到写锁的个数w。
- 在取到写锁个数后,首先判断是否已经由线程持有了锁。如果已经有线程持有锁(c!=0);则查看目前写锁的个数,如果写锁个数为0(表明存在读锁)或者持有锁的线程不是当前线程,就返回失败。
- 如果写线程为0(此时读线程也为0),并且当前线程需要阻塞,返回失败;如果通过CAS增加写线程数失败也返回失败。
- 如果c=0,w=0则设置当前线程为锁拥有者,并且返回成功。
-
读锁加锁源码:
注意:当前线程获取了写锁之后是仍旧可以获取读锁的,读写操作互斥是针对不同线程的
九、volatile
1. volatile三大特点
-
- 保证内存可见性
- 禁止指令重排序
- 不能保证原子性
2. 保证可见性原理
- 如果变量加上volatile关键字修饰,它可以保证A线程对线程栈中变量值做了修改后,会立即刷回到主内存中。而其他线程中获取到的旧的变量值作废,强迫从主内存中重新读取该变量的值。这样在任意时刻,线程看到的变量相同。
- 通过MESI缓存一致性协议保证内存可见性:
注意:
- MESI一致性协议中,数据小于一个缓存行时只对缓存行进行加锁,谁加锁成功谁修改数据,如果大于一个缓存行则会出发总线加锁机制。
- MESI协议是硬件层面的协议,是底层CPU/核中缓存和主内存交互的协议
- 计算机中所说的8核心指的是有8个ALU(算数逻辑单元),而线程数16个指的是每个核心有2个reg(寄存器),所以可以同时保存16个线程的线程栈数据,在时间分片快速切换的情况下视作16线程(实际上严格来说同一时刻最多只有8个线程在执行)。上述的MESI协议就是针对硬件层面的多级缓存设置的。当一个线程/CPU需要更改主内存中的值,还是会采取上锁的方式,锁定读取对应缓存行的总线。
3. 禁止指令重排原理:
- 指令重排:JVM为了执行效率可能会对没有逻辑依存关系的代码进行重新排序,导致多线程操作失去原子性。
- 保证指令顺序的策略:内存屏障
内存屏障由读屏障(load)和写屏障(store)组成。两两组合得到ss、sl、ll、ls(load-store)四种屏障。在指令之间插入屏障能够告诉JVM和CPU不能进行优化重排
经典案例:单例模式的双锁校验
- 问题所在代码:instance = new Singleton ();
- 新建一个对象并不是原子操作,在字节码中分为以下三步
- 为对象instance分配空间
- 初始化对象(赋值)
- 将对象instance变量指向分配的内存空间
- 由于JVM可能存在指令重排,上述的②③步没有依赖关系,可能会出现先执行第三步,再执行第二步的情况。也就是说可能会出现instance变量还未初始化完成,其余线程就判断其不为null而返回了一个初始化未完成的半成品的情况。
- 正确代码如下:
// 1、单例类只能有一个实例。
// 2、单例类必须自己创建自己的唯一实例。
// 3、单例类必须给所有其他对象提供这一实例。
class Singleton {
// 只有一个实例化对象,由自己创造
// volatile保证代码顺序、保证内存共享
// 在线程A执行new操作过程中,别的线程可以进行if()判断,但无法获取锁
// 提高了别的线程的执行效率,也保证了new操作的原子性
// 最后只创建了一个对象,并且所有线程返回的对象都是同一个
private volatile static Singleton uniqueInstance;
private Singleton() { // 构造方法为private
}
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
// 加类锁,保证构造方法只被一个线程调用
// 加锁保证new方法的原子性(volatile无法保证原子性)
synchronized (Singleton.class) {
// 重新判断(很重要),防止在竞争锁的时间中其他线程已经将唯一对象实例化
if (uniqueInstance == null)
uniqueInstance = new Singleton();
}
}
return uniqueInstance;
}
}
- 案例分析:
- volatile的两个作用:保证内存可见、禁止指令重排
- synchronized的作用:注意加的是类锁(.class),所有类实例只有一个实例能够获取锁,保证new对象的操作原子性,只能由一个线程来new对象
- 双重校验:
竞争锁前校验 --> 如果不为空,不再竞争锁,提高效率
得到锁之后校验 --> 防止在上锁的过程中其他线程已经new出来对象
4. 为什么不能保证原子性:
- 失效数据导致的操作不会撤回
十、线程池
1. 使用原因:
- 在面向对象编程中,创建销毁十分浪费时间,因为创建一个对象涉及到内存资源和其他资源的获取(用户态和内核态切换)。在Java中更为明显,虚拟机试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能的减少创建和销毁对象的次数,特别是一些很耗费资源的对象的创建和销毁,这就是“池化资源”技术的产生原因。
2. 线程池优点
-
- 降低资源消耗:重用已存在线程,减少对象创建销毁的开销。
- 提高响应速度:可有效控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争
- 提高线程的可管理性:线程是稀缺资源,如果无限创建,不仅会消耗系统资源,还会降低系统稳定性。使用线程池可以进行统一的分配,调优和监控
3.Executors创建线程池:
-
Executors提供了以下四种线程池
-
SingleThreadPool:
- 单线程线程池,只有一个线程串行执行所有任务;
- 如果这个线程因为异常结束,会有一个新的线程代替它。此线程池能够保证所有任务按提交顺序执行。
-
FixedThreadPool:
- 固定大小的线程池,每次提交一个任务就创建一个线程,直到线程数达到线程池的最大容量;
- 如果某个线程因异常结束,会有一个新线程补充;运行过程中线程数量是保持不变的
-
CachedThreadPool
- 可缓存线程池(动态的),如果线程池大小超过了处理任务需求,会回收部分空闲线程;当任务数增加时,又会增加一些线程来处理任务
- 此线程池不对容量做限制
-
ScheduledThreadPool:
- 大小无线的线程池
- 此线程池支持定时以及周期性执行任务的要求
-
-
上述4种线程池的缺点:
-
newSingleExecutor / newFixedThreadPool:
这两个队列的请求队列为Linked Blocking Queue,该队列若不指定容量大小,默认为无限大。一直堆加的任务可能会造成OOM。 -
newCachedThreadPool:
该线程池的请求队列短,任务会在线程池中不断开辟新线程执行。而其线程池最大容量为Integer.MAX_VALUE,可能会创建非常多的线程,甚至导致OOM -
newSchedukedThreadPool:
该线程池本身容量就为无限大,可能OOM
-
4. submit()和execute()
-
- 提交任务类型不同
submit() --> Runnable + Callable类型任务
execute() --> Runnable类型任务
- 提交任务类型不同
-
- 返回值不同
submit()方法可以返回持有计算结果的Future对象,而execute()方法不行
- 返回值不同
-
- 异常处理区别
submit()方法方便处理异常。execute会直接抛出任务执行时的异常,submit会吃掉异常,可通过Future的get方法将任务执行时的异常重新抛出
- 异常处理区别
-
- 所属类不同
execute所属顶层接口是Executor,submit所属顶层接口是ExecutorService,实现类ThreadPoolExecutor重写了execute方法,抽象类AbstractExecutorService重写了submit方法
- 所属类不同
5. 推荐使用ThreadPoolExecutor
-
使用该方法创建线程池,能够显示的规定线程池的大小、线程销毁前等待时间、等待队列大小、线程任务溢出策略。能够更好的规定和控制线程池的运行。其中共有以下六项参数
-
线程的增加过程分为四个阶段:
- 线程数<核心线程数时,直接新建线程
- 线程数≥核心线程数,放入等待队列(队列未满)
- 等待队列已满,则再新建线程,但要满足线程数<最大线程数
- 等待队列已满,线程数=最大线程数后,采用指定的饱和策略
-
饱和策略:默认为第一种(抛出异常拒绝处理新任务)
十一、ThreadLocal
1. 概念:
ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。主要可以用作数据隔离,填充的数据只属于当前线程,对其他线程是隔离的,可以防止自己的变量被其他线程修改。
2. 包含关系说明:
-
每个线程中包含一个ThreadLocal
-
JDK 的实现里面这个 Map 是属于 Thread,而非属于 ThreadLocal。ThreadLocal 仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在 Thread 里面。ThreadLocalMap 属于 Thread 也更加合理
-
还有一个更加深层次的原因,这样设计不容易产生内存泄露。如果是ThreadLocal持有ThreadLocalMap,那么ThreadLocal 持有的 Map 会持有 Thread 对象的引用,只要 ThreadLocal 对象存在,那么 Map 中的 Thread 对象就永远不会被回收。ThreadLocal 的生命周期往往比线程要长,所以这种设计方案很容易导致内存泄露
3. ThreadLocalMap结构
-
查看get() 源码,可以发现是从threadLocals中取数据的
-
每个线程Thread都维护了自己的threadLocals变量,所以在每个线程创建ThreadLocal的时候,实际上数据是存在自己线程Thread的threadLocals变量里面的,别人没办法拿到,从而实现了隔离,结构如下图
-
一个Entry对象包括ThreadLocal和value两个变量,而且Entry是继承弱引用(Weak Reference)的,threadLocals是用数组存储的各个Entry对象的
问题:ThreadLocalMap为什么用数组?用数组怎么解决Hash冲突?
回答:
-
- 用数组是因为要存储多个对象,我们开发过程中可以一个线程可以有多个TreadLocal来存放不同类型的对象的,但是他们都将放到你当前线程的ThreadLocalMap里,所以肯定要数组来存
- Hash冲突解决:ThreadLocal相同 --> 刷新、不同 --> 找下一个空位置
4. 原理分析![在这里插入图片描述](https://img-blog.csdnimg.cn/20210623102309886.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0RDOTc1MQ==,size_16,color_FFFFFF,t_70)
5. 常用方法
6. 线程池中使用ThreadLocal
- 问题:容易导致内存泄漏
- 产生原因:
在线程池中线程的存活时间太长,往往都是和程序同生共死的,这样 Thread 持有的 ThreadLocalMap 一直都不会被回收,再加上 ThreadLocalMap 中的 Entry 对 ThreadLocal 是弱引用(WeakReference),所以只要 ThreadLocal 结束了自己的生命周期是可以被回收掉的。Entry 中的 Value 是被 Entry 强引用的,即便 value 的生命周期结束了,value 也是无法被回收的,导致内存泄露。 - 解决方法:
在finally代码块中手动清理ThreadLocal中的value,调用ThreadLocal的remove()方法
7. 源码分析
十二、问题解析
1. synchronized同步阻塞队列中线程的唤醒方法是怎样的?
-
进入同步阻塞状态的线程并不像进入等待队列的线程一样,有着显式的唤醒方法(wait --> notify/notifyAll)
-
任意线程对同步块的访问,首先要获取其锁对象的监视器,如果获取失败(当前锁被其他线程持有,并未释放),则该线程进入同步队列,线程的状态变成BLOCKED;当访问该锁对象的前驱(获得了锁的线程)释放了锁,则该释放操作会唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取
-
加锁/释放锁过程的字节码反编译成JVM指令如下,可以看出synchronized同步锁的获取主要是判断对象监视器操作(可重入实现原理也是对监视器计数)
-
这里monitorexit有两个是因为线程出现异常后也会释放锁,所以monitorexit的数量会比monitorenter的数量多,无需一一对应,入口只能有一个,这是为了同步,出口可以有多个,这是为了尽量保证在不同情况下,锁都能够被顺利释放
-
监视器概念:
-
每一个Java对象都以某种逻辑关联监视器,为了实现监视器互斥功能,每个对象也都关联着一个锁(互斥量 mutex)。
-
关联锁的位置:对象头中指向重量级锁的指针(对象头结构如下)
-
一旦一段代码被嵌入到一个synchronized关键字中,意味着放入了监视区域。监视区域是互斥的,同一时刻只有一个线程能够进入监视器的临界区。
-
2. 为什么wait()、notify()/notifyAll()方法被定义在Object类中?
- 在Java中,任何对象都可以作为锁。
- 而在Thread类中,并没有可供任何对象都能使用的锁,所以这些方法定义在Object类中。
- 如果将上述方法定义在Thread类中,在一个线程拥有多个锁的情况下,难以区分和管理,实现起来十分复杂。
3. 为什么Thread类中的sleep()和yield()方法是静态的?
- Thread类的sleep()方法和yield()方法都是需要在当前执行的线程上运行才有意义的,其他处于阻塞状态的线程调用这些方法没有意义。所以这些不需要根据类实例加以区分,在类的内部是可以共享的。(如果没有获得执行权,则通过实例方法调用也没用;如果不是并发的,则使用这些方法无意义)
- 如果这两个方法是非静态的:
- yield() --> 调用其他线程的yield()方法意义不大,因为其他线程可能不在运行
- sleep() --> 该线程调用其他线程的sleep()方法容易造成死锁
4. 如何停止一个正在运行的线程?
-
- 使用退出标志,使run()方法结束,然后线程自然终止(推荐)
- 使用stop()方法强行终止(不推荐)
- 使用interrupt()方法中断线程
5. join()方法说明
- 在线程t1中调用t2的join方法时,只有t1会等待t2线程执行完毕退出,而别的线程依旧在跟t2方法竞争CPU执行权。
- join()方法一般用在main()线程中,主线程等待其他线程结束再结束自身。
6. 线程优先级
- 线程优先级并不绝对代表线程的执行顺序,优先级高的线程获得CPU执行权的概率更大。
- 线程的优先级用数字表示,范围从1~10,默认是5
- 优先级查询 --> getPriority()
优先级设置 --> setPriority()
7. 偏向锁存在的原因
- (1) 偏向锁中记录了获取了这把锁的线程id,如果下次仍然是这个线程尝试获取锁,可以省去竞争的过程直接获取,能够提升性能。
(2) 在大量的应用和实践中,发现一把锁有很多情况下往往只有一个线程要使用它,若不使用偏向锁会频繁发生竞争,导致性能降低。