Java八股复习指南-并发
Java并发
线程
进程与线程
进程:
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是一个从创建、运行到消亡的动态过程。
线程:
线程是比进程更小的执行单位,一个进程可以产生多个线程。线程的产生或者切换时的负担比进程要小得多。
多个线程共享进程的堆和方法区,而程序计数器、虚拟机栈和本地方法栈是私有的
程序计数器:记录当前线程的执行位置,是为了线程切换时能恢复到正确的执行位置
虚拟机栈和本地方法栈:保证线程中的局部变量不被别的线程访问到
线程的实现
在 Java 中,实现多线程的主要有以下四种
- 继承
Thread
类,重写run()
方法;
可以单独执行run方法吗?
调用start()
方法,会启动线程并使线程进入就绪态,等待时间片的分配,从而自动执行run()
方法。
直接执行run()
方法,则会把run()
方法作为main中的一个方法去执行,并不是一个多线程的工作。
- 实现
Runnable
接口,实现run()
方法,并将Runnable
实现类的实例作为Thread
构造函数的参数 target;
可以将任务和线程的逻辑分离,提供了更好的灵活性和重用性。你可以将一个 Runnable
对象传递给多个线程实例。
- 实现
Callable
接口,实现call()
方法,然后通过FutureTask
包装器来创建Thread
线程;
可以返回结果并处理异常。Callable
的 call()
方法可以返回值(V
),并且可以抛出异常(Exception
)。
- 通过
ThreadPoolExecutor
创建线程池,并从线程池中获取线程用于执行任务;
不使用Executors
创建,而是用ThreadPoolExecutor
创建
通过工厂模式,将创建产品实例的权利移交工厂,我们不再通过new
来创建我们所需的对象,而是通过工厂来获取我们需要的产品。降低了产品使用者与使用者之间的耦合关系;创建线程池没有显式new
,而是通过Executors
这个静态方法newCaChedThreadPool
来完成的;
严格来说,Java 就只有一种方式可以创建线程,那就是通过new Thread().start()
创建。不管是哪种方式,最终还是依赖于new Thread().start()
。
Thread
类与Runnable
接口比较:
实现一个自定义的线程类,可以有继承Thread
类或者实现Runnable
接口的方式,由于java单继承、多实现的特性,Runnable
接口使用比Thread
更加灵活
Callable
接口:
通常来说,我们使用Runnable
和Thread
来创建一个新的线程。但是它们有一个弊端,就是run
方法是没有返回值的。而有时候我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值。
JDK提供了Callable
接口与Future
类为我们解决这个问题,这也是所谓的“异步”模型。
线程的六种状态:
// Thread.State 源码
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
- NEW:
初始状态,线程被创建出来但没有被调用start()
- 不能反复调用同一个线程的
start()
方法 - 处于TERMINATED状态的线程不能再次调用
start()
方法
- RUNNABLE:
运行状态。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待其他系统资源(比如I/O)。
- BLOCKED:
阻塞状态。处于BLOCKED状态的线程正等待锁的释放以进入同步区。
- WAITING
等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。
以下方法使进程进入等待状态:
Object.wait()
Thread.join()
LockSupport.park()
- TIMED_WAITING
超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。
- TERMINATED
终止状态。此时线程已执行完毕。
Java线程间的通信:
锁与同步
在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。线程和锁的关系,我们可以用婚姻关系来理解。一个锁同一时间只能被一个线程持有。也就是说,一个锁如果和一个线程“结婚”(持有),那其他线程如果需要得到这个锁,就得等这个线程和这个锁“离婚”(释放)。
等待/通知机制
Java多线程的等待/通知机制是基于Object
类的wait()
方法和notify()
, notifyAll()
方法来实现的。
信号量
使用volitile
关键字实现信号量。volitile
关键字能够保证内存的可见性,如果用volitile
关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。
其他通信相关
join方法
join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。
sleep方法
sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间。
wait方法与sleep方法的区别:
sleep仅仅释放cpu资源,不会释放锁,所以易死锁。而wait方法会释放cpu资源,也会释放当前锁。
为什么wait()方法不定义在Thread中、sleep()方法定义在Thread中?
wait()
方法:是要线程释放占用的对象锁,因此操作的对象锁是在对象类层面而不是某个线程
sleep()
方法:仅仅是让线程暂停执行,不涉及到对象的操作
多线程
并发与并行的区别
- 并发:两个及以上的作业在同一时间段内同时进行
- 并行:两个及以上的作业在同一时刻进行
同步与异步的区别
- 同步:发出一个调用后,在没有得到结果之前,就不可以返回,必须一直等待
- 异步:发出一个调用后,不用等待返回结果,直接返回。
死锁
什么是线程死锁?
多个线程同时被阻塞,其中的一个或多个都在等待某资源的释放,由于线程被无期限地阻塞,因此程序无法正常终止。
如何预防和避免线程死锁?
如何预防?
- 破坏请求与保持的条件:一次性申请所有的资源
- 破坏不剥夺条件:占用部分资源的线程在申请其他资源时,如果被占用,可以主动释放占用的资源。
- 破环循环等待的条件:按顺序申请资源,按反序释放资源。
原理篇JMM(Java内存模型)
Java内存模型基础知识
Java中采取的是一种共享内存的并发模型
这里的共享,并不是指线程A与线程B之间完全可见,而是针对线程的本地内存与主内存之间变量共享。
在写操作时,线程操作变量,是只对本地内存中的变量进行修改,然后线程再将已修改的的变量刷新到主内存中。
而在读操作时,主内存中修改的变量会同步到线程的本地内存,线程也是只读取本地内存。
在其中发挥作用,可以监控到变量的变化的,就是JMM的作用了。
重排序与happens-before(基础概念)
重排序
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
为了防止问题出现,引入顺序一致性模型
顺序一致性模型与JMM实现
顺序一致性模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。
正因为它是一个理论模型,在现实中难以实现,JMM的实现方针是,在确保不改变程序结果的前提下,尽可能为重排序提供便利
happens-before
JMM的实现要考虑到两方面:
一方面,编译器与优化器需要JMM对它们的束缚尽可能的少,这样他们就能尽可能多的进行优化。
另一方面,程序员在编写多线程程序时,希望简单依赖程序中的顺序理解数据的交互与线程的执行顺序。
因此JMM提供了happens-before的规则,程序员只要遵循这个规则,就可以保证程序正确执行。
在Java中,有以下天然的happens-before关系:
-
程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
-
监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
-
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
-
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
-
start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作、
-
join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。
volatile
volatile
主要由以下两个功能
- 保证内存可见性
- 禁止重排序,但不保证原子性
如何保证变量的可见性?
强制在主存中读写变量:
当线程对volatile
修饰的变量进行写操作时,JMM会立即将修改后的变量刷新到主存中。
当线程进行读操作时,JMM会使volatile
修饰的变量的缓存立即失效,使线程从主存中读取变量数据。
如何禁止重排序?
通过插入特定的 内存屏障 的方式来禁止指令重排序。内存屏障的作用是:
- 阻止屏蔽两边的指令重排序
- 强制将写缓冲区/高速缓冲区的缓存写入主存中,或者让缓存中数据失效
volatile的用途
在保证变量可见性这点上,可以用来作为"轻量级"的锁,对比锁,volatile只能保证单个变量具有原子性,而锁可以保证整个临界区代码的原子性。
在禁止重排序上,也可以防止一些重排序的发生。
synchronized与锁
我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有以下三种形式:
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
四种状态:无锁、偏向锁、轻量级锁、重量级锁
锁的状态是通过Java对象头来实现的
无锁->偏向锁->轻量级锁->重量级锁
- 偏向锁:
顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
更好理解的就是,对锁置一个true,如果为true,则代表没有资源竞争,那么就不需要进行加锁/解锁等操作,而是当作普通变量一样获取。如果有资源竞争,则将该值置为false,走加锁/解锁的流程
偏向锁的实现:
如果占用偏向锁的线程退出了,则涉及到一个锁ID的切换的问题。偏向锁会在第一次进入同步块时存放偏向的线程A的ID,这样在该线程A每次进入和退出同步块时不用进行同步操作消耗资源。
如果在线程A退出后,另一个线程B占用了这个锁,此时偏向锁的ID并不是指向线程B的,所以线程B会尝试修改偏向锁的ID指向B,如果成功,则说明线程A已经退出了。如果失败,则说明线程A、B在竞争该锁,此时偏向锁会升级成轻量级锁。
- 轻量级锁:
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
轻量级锁的加锁:
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
- 重量级锁:
在轻量级锁状态下,如果有第三个来访时,就会自动升级成重量级锁
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 | 适用于只有一个线程访问同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度。 | 如果始终得不到锁竞争的线程使用自旋会消耗CPU。 | 追求响应时间。同步块执行速度非常快。 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU。 | 线程阻塞,响应时间缓慢。 | 追求吞吐量。同步块执行速度较长。 |
CAS与原子操作
乐观锁与悲观锁
悲观锁:
它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
乐观锁:
乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常是使用一种称为CAS的技术来保证线程执行的安全性。乐观锁天生免疫死锁。
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
CAS
如果有一个多个线程共享的变量
i
原本等于5,我现在在线程A中,想把它设置为新的值6;
CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
- V:要更新的变量(var),指代变量(i)
- E:预期值(expected),指旧值(5)
- N:新值(new),指要设置的值(6)
比较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。
AQS:
实现各种同步器(如锁、信号量、读写锁等)的基础
核心:
- 状态:state
表示同步状态,用于控制对共享资源的访问
- 控制线程抢锁和配合的FIFO队列(双向链表)
每个等待获取同步状态的线程都会被封装成一个 Node
对象,并按照获取的顺序排入队列。
- 期望协作工具类去实现的获取/释放等重要方法
线程池原理
线程池的参数
五个必需参数:
- int corePoolSize:核心线程最大值(核心线程会一直存在,非核心线程如果长时间闲置,会被销毁)
- int maximumPoolSize:线程总数最大值(核心线程+非核心线程)
- int keepAliveTime:非核心线程闲置超时时长
- Timeunit unit:keepAliveTime的单位
- BlockingQueue workQueue:阻塞队列,维护等待执行的Runnable任务对象
主要的任务处理流程:
如何做到线程复用
ThreadPoolExecutor在创建线程时,会将线程封装成工作线程worker,并放入工作线程组中,然后这个worker反复从阻塞队列中拿任务去执行。
首先去执行创建这个worker时就有的任务,当执行完这个任务后,worker的生命周期并没有结束,在while
循环中,worker会不断地调用getTask
方法从阻塞队列中获取任务然后调用task.run()
执行任务,从而达到复用线程的目的。只要getTask
方法不返回null
,此线程就不会退出。
如何创建线程池
-
通过
ThreadPoolExecutor
构造函数来创建(推荐)。 -
通过
Executor
框架的工具类Executors
来创建。
参考-深入浅出Java多线程(https://redspider.gitbook.io/concurrent)