JAVA并发编程—JUC-基础篇 (java.util.concurrent)
JUC概述
- 进程与线程的区别
- 线程的状态(New新建,Runnable准备就绪,Blocked阻塞,Waiting不见不散,Timed_waiting过时不候)
- wait和sleep的区别:
- sleep是Thread的静态方法,wait是Object的方法,任何对象实例都能调用。
- sleep不会释放锁,它也不需要占用锁。wait会释放锁,但调用它的前提是当前线程占有锁(即代码要在synchronized中)。
- 它们都可以被interrupted方法中断。
- 并发和并行的区别:同一时刻多个线程在访问同一个资源,多个线程对一个点(春运抢票);多项工作一起执行,之后再汇总(泡面的例子)。
- 管程、用户线程和守护线程
管程
Monitor监视器,锁,是一种同步机制,保证同一个时间,只有一个线程访问被保护数据或者代码。JVM同步基于进入和退出,使用管程对象实现的。
用户线程和守护线程
- 用户线程:自定义线程(主线程结束了,用户线程还在运行,JVM存活)
- 守护线程:默默执行在后台的线程,比如垃圾回收(没有用户线程了,都是守护线程,JVM结束)
Lock接口
Synchronized关键字
同步锁。
- 修饰一个代码块;
- 修饰一个方法;
- 修饰一个静态方法;
- 修改一个类。
多线程编程的步骤:1. 创建资源类,在资源类中创建属性和操作方法;2. 在资源类操作方法,判断,干活,通知;3. 创建多个线程,调用资源类的操作方法;
卖票示例:
1 | class Ticket { |
什么是Lock接口
手动实现上锁、释放锁。
- 可重入锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class LockTicket {
private int number = 30;
//创建可重入锁
private final ReentrantLock lock = new ReentrantLock();
public void sale() {
//上锁
lock.lock();
try {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + ": 卖出 :" + (number--) + "剩下:" + number);
}
}finally {
//解锁
lock.unlock();
}
}
}
Lock和synchronized的不同:
- Lock是一个接口,而synchronized是Java中的关键字;
- synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unlock()去释放锁,则很可能造成死锁现象,这也是为什么需要在finally块中释放锁;
- Lock可以让等待锁的线程响应中断,而synchronized缺不行,等待的线程会一直等待下去,不能够响应中断;
- 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到;
- Lock可以提高多个线程进行读操作的效率。
进程间通信
- 判断;
- 干活;
- 通知;
1 |
|
- 虚假唤醒问题:wait()方法特点,在哪里睡,在哪里醒,就会导致if判断失效。所以解决方法是把if改成while。
Lock实现:
1 |
|
- 进程间定制化通信:启动三个线程,按照“AA打印5次,BB打印10次,CC打印15次”进行十轮
1 |
|
集合线程安全
- List
示例:
1 |
|
报异常:java.util.ConcurrentModificationException
原因是List.add()方法没有synchronized关键字修饰
解决方案(一):vector
Listlist = new Vector<>(); // JDK 1.0 Vector类中的方法有synchronized关键字修饰 解决方案(二):Collection工具类
Listlist = Collections.synchronizedList(new ArrayList<>()); // Collections.synchronizedList返回同步列表 解决方案(三):CopyOnWriteArrayList
Listlist = new CopyOnWriteArrayList(); // 写时复制技术,并发读、复制一份新内容,独立写 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
HashSet
解决方案:CopyOnWriteArraySetHashMap
解决方案:ConcurrentHashMap
多线程锁
锁的范围
案例:
1 | // 对应8 |
- 1、2锁的是当前对象;3新增的普通方法与锁无关,所以先执行;4有两个对象,两把锁,各锁各的;5、6锁的范围是类Class;7、8一个锁的是对象,一个锁的是类。
- 总结:synchronized实现同步的基础是Java中的每一个对象都可以作为锁。
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的Class对象。
- 对于同步方法块,锁是Synchronized括号里配置的对象。
公平锁和非公平锁
卖票案例中,三个线程,可能只有一个线程卖了全部的票,导致其他线程饿死,这是非公平锁的情况。
- 非公平锁:线程饿死、效率高; 公平锁:阳光普照、效率相对低。
可重入锁
- 又叫递归锁,synchronized(隐式)和Lock(显式)都是可重入锁(一把锁可进入各个区域)。
1 |
|
死锁
- 两个或者两个以上进程在执行过程中,因为争夺资源而造成一种互相等待的现象。如果没有外力干涉,它们无法再执行下去。
- 原因:1. 系统资源不足 2. 进程运行推进顺序不合适 3. 资源分配不当
1 |
|
- 验证是否是死锁
- jps -l类似linux ps -ef;先获取当前运行程序的进程号
- jstack jvm自带的堆栈跟踪工具,根据进程号查询。
Callable接口
创建线程的多种方式
- 继承Thread类;
- 实现Runnable接口;
- 实现Callable接口;
- 线程池的方式。
Runnable和Callable接口的区别
(1)是否有返回值:无;有
(2)是否抛出异常:无;有
(3)实现方法名称:run();call()
1 |
|
辅助类
CountDownLatch
减法计数器;countDown()用来减一,该线程不会阻塞;当一个或多个线程调用await()方法时,这些线程会阻塞;当计数器的值变为0时,因await方法阻塞的线程会被唤醒,继续执行。
示例:
1 |
|
CyclicBarrier
- 循环栅栏,加法计数器
- 示例:
1 | public class CyclicBarrierDemo { |
Semaphore
- 计数信号量;信号量维护了一个许可集,如有必要,在许可可用前会阻塞每一个acquire(),然后再获取该许可。每个release()添加一个许可,从而可能释放一个正在阻塞的获取者。一个场景就是控制并发量。
- 示例:
1 |
|
读写锁
悲观锁和乐观锁
悲观锁:不支持并发操作,频繁地上锁、释放锁,效率低。
乐观锁:支持并发,版本号控制,适用于多读的应用类型,这样可以提高吞吐量。
- 乐观锁的缺点:
ABA 问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 “ABA”问题。
JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。
表锁和行锁
行锁(Row Lock):
a. 锁定范围: 行锁是对表中的一行数据进行锁定,而不是锁定整个表。这意味着其他事务仍然可以访问表中的其他行,不受锁定行的影响。
b. 适用场景: 适用于高并发读写的情况,允许多个事务同时访问表的不同行,降低了锁的争用。表锁(Table Lock):
a. 锁定范围: 表锁是对整个表进行锁定,当一个事务获取了对表的锁时,其他事务无法同时访问该表,即使它们要访问的是不同的行。
b. 适用场景: 适用于需要保证整个表的一致性的场景,例如在对整个表进行大批量更新或者维护操作时。粒度:
a. 行锁: 锁定的粒度更细,只影响到实际需要修改的行,不会对表的其他部分产生影响。
b. 表锁: 锁定的粒度更大,会阻塞对整个表的访问,可能导致并发性能下降。性能:
a. 行锁: 在高并发读写的场景中性能较好,因为允许多个事务同时访问表的不同行。
b. 表锁: 在高并发写入的场景中可能会导致性能问题,因为需要等待对整个表的锁释放。死锁风险:
a. 行锁: 会死锁;锁定粒度小,发生锁冲突的概率较低。
b. 表锁: 无死锁;锁定粒度大,发生锁冲突的概率最高。
读锁和写锁
- 读锁:共享锁,会死锁。如1修改要等2读之后,2修改要等1读之后。
- 写锁:独占锁,会死锁。
读写锁:一个资源可以被多个读线程访问,或者可以被一个写线程访问,但是不能同时存在读写进程,读写互斥,读读共享的。
- 无锁,多线程抢断资源;
- 添加锁,使用synchronized和ReentrantLock,都是独占的,每次只能来一个操作;
- 读写锁,读读可以共享,提升性能,同时多人进行读操作,缺点(1造成锁饥饿,一直读没有写;2读时候,不能写,只有读完成之后,才可以写,写操作可以读)
- 锁降级:将写入锁降级为读锁,读锁不能升级为写锁
读写锁案例
1 |
|
阻塞队列
通过一个共享的队列,使数据由队列的一端输入,从另外一端输出;当队列空时,获取元素的线程会被阻塞;当队列满时,添加元素的线程会被阻塞。
为什么需要BlockingQueue? 好处是我们不需要关心什么时候需要阻塞线程,什么时候需要唤醒线程,BlockingQueue一手包办。
BlockingQueue
- ArrayBlockingQueue 由数组结构组成的有界阻塞队列
- LinkedBlockingQueue 由链表结构组成的有界(integer.MAX_VALUE)阻塞队列
- DelayQueue 使用优先级队列实现的延迟无界阻塞队列
- PriorityBlockingQueue 支持优先级排序的无界阻塞队列
- SynchronousQueue 不存储元素的阻塞队列,也即单个元素的队列
- LinkedTransferQueue 由链表结构组成的无界阻塞队列
- LinkedBlockingDeque 由链表结构组成的双向阻塞队列
核心方法:
线程池
优势:线程池做的工作只是控制运行的线程数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量,超出数量的线程排队等候,等其他线程执行完毕,再从队列中取出任务来执行。
特点:
- 降低资源消耗:通过重复利用已创建的线程降低线程创建和销毁造成的消耗;
- 提高响应速度:当任务到达时,任务可以不需要等待线程创建就能立即执行;
- 提高线程的可管理性:利用线程池进行统一的分配,调优和监控。
线程池的使用方式
- Executors.newFixedThreadPool(int) 一池N线程
- Executors.newSingleThreadExecutor() 一个任务一个任务执行,一池一线程
- Executors.newCachedThreadPool() 线程池根据需求创建线程,可扩容
ThreadPoolExecutor的七个参数
- int corePoolSize 常驻线程数量
- int maximumPoolSize 最大线程数量
- long KeepAliveTime, TimeUnit unit 线程存活时间
- BlockingQueue
workQueue 阻塞队列 - ThreadFactory threadFactory 线程工厂
- RejectedExecutionHandler handle 拒绝策略
线程池的工作流程:
JDK内置的拒绝策略:
自定义线程池
一般情况下,不允许使用Executors创建线程池,而是通过自定义ThreadPoolExecutor的方式,以规避资源耗尽的风险。
1 |
|
Fork/Join
Fork:把一个复杂任务进行分拆,大事化小
Join:把分拆任务的结果进行合并
1 |
|
异步回调
CompletableFuture
无返回值的异步调用
1
2
3
4
5
6
7
CompletableFuture<Void> cf = CompletableFuture.runAsync(()->{
// ...
});
cf.get();有返回值的异步调用
1 |
|
面试题
1. 并发和并行
并发:一台机器上“同时”处理多个任务,但同一时刻只有一个在发生;
并行:在同一时刻,在多台处理器上同时处理多个任务。
2. 进程、线程、管程
进程:应用程序的一次执行过程,动态的,包括进程从创建、运行和消亡的过程。系统运行程序的基本单元。
线程:轻量级线程。同类的线程共享进程的堆和方法区,每个线程有自己的程序计数器、虚拟机栈和本地方法栈;操作系统调度的基本单元。
管程:Monitor,锁,一种同步机制。JVM同步基于管程,底层由C++实现。当JVM对象被用作同步锁时,JVM会为该对象关联一个Monitor;而该对象不再被用作同步锁或对象被垃圾回收时,Monitor可能会被JVM内部释放或重新利用。
3. 线程的分类
1) 用户线程;2)守护线程:为其他线程服务的。thread.setDaemon(true)。没有用户线程,JVM结束。
4. Future接口(FutureTask实现类) —JDK5
特点:多线程,有返回值,异步任务
优点:和线程池异步多线程任务配合使用效率高;
缺点:get() 阻塞;isDone()轮询耗费CPU资源。
5. CompletableFuture —JDK8
- 出现的原因:1)针对Future的缺点;2)传入回调参数,实现复杂功能,以观察者模式。
- 接口CompletionStage和类CompletableFuture
- 静态构造方法:1)runAsyn无返回值;2)supplyAsyn有返回值。
- Executor参数说明:若未指定,则使用默认的ForkJoinPoolCommonPool();自定义线程池为非守护线程,所以就会继续执行。用ForkJoinPool是守护线程,可能会出现main线程结束后,JVM也结束了。
- CompletableFuture可传入回调对象,当异步任务完成或发生异常时,自动调用回调对象。
6. 函数式接口
接口名称 | 方法名称 | 参数 | 返回值 |
---|---|---|---|
Runnable | run | 无 | 无 |
Function | apply | 1 | 有 |
Consume | accept | 1 | 无 |
Supplier | get | 无 | 有 |
BiConsumer | accept | 2 | 无 |
7. CompletableFuture常用方法
获取结果和主动触发计算:
- get();
- get(long timeout, TimeUnit unit);
- join() 和get一样用,只是不抛出异常;
- getNow(T valuelfAbsent) —>计算完成就返回正常值,否则返回备胎值(传入的参数),立即获取结果不阻塞
- complete(T value) —->是否打断get方法立即返回括号值
对计算结果进行处理:
- thenApply:计算结果存在依赖关系,串行化,有异常叫停
- handle:有异常也往下走
- thenAccept:接受任务的处理结果,并消费处理,无返回结果
如果执行第一个任务传入了自定义线程池,thenRun执行第二个任务,则共用同一个线程池;若用thenRunAsync则各用各的。
原因:带Async的方法底层调用uniRunStage(asyncPool, action)更改线程池为默认的,而不是自定义的。对计算速度进行选用:
playA.applyToEither(playB, f -> {
return f + “ is winner”;
});对计算结果进行了合并:
两个CompletableStage任务都完成后,最终能把两个任务的结果一起交给thenCombine来处理;先完成的先等着,等待其他分支任务。
8. 悲观锁和乐观锁
悲观锁:synchronized和Lock的实现类,适合写操作多的场景;
乐观锁:版本号机制Version,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读操作多的场景。
9. synchronized线程8锁问题
- 对于普通同步方法,锁的是当前实例对象,通常指this,所有的同步方法用的都是同一把锁—>实例对象本身
- 对于静态同步方法,锁的时当前类的Class对象
- 对于同步方法块,锁的时synchronized括号内的对象
10. 从字节码角度分析synchronized实现
- synchronized同步代码块:实现使用的是monitorenter和monitorexit指令;一般是一个enter两个exit,一个正常情况退出锁,一个异常情况退出锁。
- synchronized普通同步方法:ACC_SYNCHRONIZED访问标志。
- synchronized静态同步方法:ACC_STATIC、ACC_SYNCHRONIZED访问标志。
为什么任何一个对象都可以成为一个锁?
C++源码:ObjectMonitor.java—>ObjectMonitor.cpp—>ObjectMonitor.hpp
每个对象天生都带着一个对象监视器,每一个被锁住的对象都会和Monitor关联起来。
11. 公平锁与非公平锁
为什么会有公平锁/非公平锁的设计?为什么默认非公平?
非公平锁能更充分地利用CPU的时间片,尽量减少CPU空闲状态时间。减少线程切换的开销。可能产生线程饥饿。什么时候用公平?什么时候用非公平?
如果为了更高的吞吐量,很显然非公平锁是比较合适的,因为节省了很多线程切换的时间,吞吐量自然就上去了;否则就用公平锁,大家公平使用。
12. 可重入锁
- 隐式锁(即synchronized关键字使用的锁);
- 显式锁(即Lock)也有ReentrantLock这样的可重入锁;
Objectmonitor底层会维护一个计数器每lock一次就+1,每unlock一次-1,0表示没有线程占用。(因此lock和unlock要成对出现)
13. 死锁及排查
死锁产生原因:
- 系统资源不足
- 进程运行推进顺序不合适
- 系统资源分配不当
排查:
- jps -l + jstack 进程编号
- jconsole图形化
14. 线程中断
- 一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止,所以,Thread.stop,Thread.suspend,Thread.resume都已经被废弃了
- Java中没有办法立即停止一条线程,Java提供了一种用于停止线程的协商机制—-中断,中断的过程完全需要程序员自行实现。
三个方法:
- public void interrupt() 仅仅是设置线程的中断状态为true,发起一个协商而不会立刻停止线程
- public static boolean interrupted() 判断线程是否被中断并清除当前中断状态,重新设置为false
- public boolean isInterrupted() 判断当前线程是否被中断(通过检查中断标志位)
如何停止终端运行中的线程?
通过一个volatile变量实现
1
2
3
4
5
6
7
8
9
10new Thread(() -> {
while (true) {
if (isStop) {
System.out.println(Thread.currentThread().getName() + " isStop的值被改为true,t1程序停止");
break;
}
System.out.println("-----------hello volatile");
}
}, "t1").start();通过AutomicBoolean
通过Thread类自带的中断API实例方法实现—-在需要中断的线程中不断监听中断状态,一旦发生中断,就执行相应的中断处理业务逻辑stop线程。
当前线程的中断标识为true,是不是线程就立刻停止?
- 如果线程处于正常活动状态,那么会将该线程的中断标志设置为true,仅此而已,被设置中断标志的线程将继续正常运行,不受影响,所以interrupt()并不能真正的中断线程,需要被调用的线程自己进行配合才行,对于不活动的线程没有任何影响。
- 如果线程处于阻塞状态(例如sleep,wait,join状态等),在别的线程中调用当前线程对象的interrupt方法,那么线程将立即退出被阻塞状态(interrupt状态也将被清除),并抛出一个InterruptedException异常。
静态方法Thread.interrupted(),谈谈你的理解?
中断标识被清空,如果该方法连续被调用两次,第二次调用将返回false;除非当前线程在第一次调用和第二次调用该方法之间再次被interrupt。
15. LockSupport
LockSupport是用来创建锁和其他同步类的基本线程阻塞原语,其中park()和unpack()而作用分别是阻塞线程和解除阻塞线程。
三种让线程等待和唤醒的方法
- 使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程
- 使用JUC包中的Condition的await()方法让线程等待,使用signal()方法唤醒线程
- LockSupport类可以阻塞当前线程以及唤醒指定被阻塞的线程
LockSupport类中的park等待和unpark唤醒
- LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(Permit),许可证只能有一个,累加上限是1。
- park/park(Object blocker)——-阻塞当前线程/阻塞传入的具体线程;
- unpark(Thread thread)——唤醒处于阻塞状态的指定线程
为什么要有LockSupport
- wait和notify方法必须要在同步代码块或者方法里面,且成对出现使用,先wait再notify才ok。
- Condition中的线程等待和唤醒方法,需要先获取锁;一定要先await后signal。
- 因为unpark获得了一个凭证,之后再调用park方法,就可以名正言顺的凭证消费,故不会阻塞,先发放了凭证后续可以畅通无阻。
为什么唤醒两次后阻塞两次,但最终结果还会阻塞线程?
因为凭证的数量最多为1,连续调用两次unpark和调用一次unpark效果一样,只会增加一个凭证,而调用两次park却需要消费两个凭证,证不够,不能放行。
16. Java内存模型之JMM
你知道什么是Java内存模型JMM吗?
JMM(Java内存模型Java Memory Model)本身是一种抽象的概念并不真实存在,它仅仅描述的是一组约定或规范。通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
为什么要有JMM,它为什么出现?作用和功能是什么?
CPU的运行并不是直接操作内存而是先把内存里面的数据读到缓存,而内存的读和写操作的时候会造成不一致的问题。通过JMM来实现线程和主内存之间的抽象关系(线程之间的共享变量存储在主内存中,每个线程都有一个自己的本地工作内存,本地工作内存中存储了该线程用来读写共享变量的副本),屏蔽各个硬件平台和操作系统的内存访问差异以实现让Java程序在各种平台下都能达到一致性的内存访问效果。
JMM没有哪些特征或者它的三大特征是什么?
- 可见性:是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。
- 原子性:指一个操作是不可被打断的,即多线程环境下,操作不能被其他线程干扰。
- 有序性:为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序话执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排可以保证串行语义一致,但没有义务保证多线程的语义也一致(可能产生“脏读”)。
JMM和volatile他们两个之间的关系?
volatile关键字:
1、可见性;2、不保证原子性;3、禁止指令重排。
happens-before先行原则你有了解过吗?
- 在JVM中,如果一个操作执行的结果需要对另一个操作可见或者代码重排序,那么这两个操作之间必须存在happens-before(先行发生)原则,逻辑上的先后关系。包含可见性和有序性的约束。
八条规则:
- 次序规则:一个线程内,按照代码的顺序,写在前面的操作先行发生于写在后面的操作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作;
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终止规则:线程中的所有操作都优先发生于对此线程的终止检测;
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
17. volatile与JMM
被volatile修饰的变量有什么特点?
- 可见性和有序性,但不保证原子性;
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中;
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量的值;
- volatile凭什么可以保证可见性和有序性?——> 内存屏障Memory Barrier
- 系统底层确认变量的ACC_VOLATILE标识标志在相应的位置加入内存屏障。
内存屏障是什么?
内存屏障(也称内存栅栏,屏障指令等)是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序。
内存屏障的分类
粗分两种:
- 读屏障(Load Barrier):在读指令之前插入读屏障,让工作内存或CPU高速缓存 当中的缓存数据失效,重新回到主内存中获取最新数据。
- 写屏障(Store Barrier):在写指令之后插入写屏障,强制把缓冲区的数据刷回到主内存中。
细分四种:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1;LoadLoad;Load2 | 保证Load1的读取操作在Load2及后续读取操作之前执行 |
StoreStore | Store1;StoreStore;Store2 | 在store2及其后的写操作执行前,保证Store1的写操作已经刷新到主内存 |
LoadStore | Load1;LoadStore;Store2 | 在Store2及其后的写操作执行前,保证Load1的读操作已经结束 |
StoreLoad | Store1;StoreLoad;Load2 | 保证Store1的写操作已经刷新到主内存后,Load2及其后的读操作才能执行 |
happens-before之volatile变量规则
当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序,这个操作保证了volatile读之后的操作不会被重排到volatile读之前。
当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。
当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序,这个操作保证了volatile写之前的操作不会被重排到volatile写之后。
读屏障:在每个volatile读操作的后面插入一个LoadLoad屏障和LoadStore屏障。
写屏障:在每个volatile写操作的前面插入StoreStore屏障;在每个volatile写操作的后面插入StoreLoad屏障。
怎么理解volatile变量的复合操作不具有原子性
对于voaltile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅仅是数据加载时是最新的。但是多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存的操作将会作废去读主内存最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须加锁同步。
举例i++的例子;
如何正确使用volatile
- 单一赋值可以,但是含复合运算赋值不可以;
- 状态标志,判断业务是否结束;
- 开销较低的读,写锁策略;当读远多于写,结合使用内部锁和volatile变量来减少同步的开销;
- DCL双端锁的发布,多线程下的解决方案适合加volatile修饰。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
// 隐患:多线程环境下,由于重排序,该对象可能未完成初始化就被其他线程读取。
// 1. 为 uniqueInstance 分配内存空间; 2. 初始化 uniqueInstance; 3. 将 uniqueInstance 指向分配的内存地址。
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
18. CAS
CAS是什么?有什么缺点
- 没有CAS之前:多线程环境中使用valatile+synchronized保证线程安全i++;
- 使用CAS之后:多线程环境中使用原子类CAS保证线程安全i++,类似于乐观锁。
CAS(compare and swap),用于保证共享变量的原子性更新,它包含三个操作数—内存位置、预期原值与更新值。
缺点:
- 循环时间长开销大。底层getAndAddInt方法有一个do while,如果CAS失败,会一直进行尝试,如果CAS长时间一直不成功,可能会给CPU带来很大开销;
- ABA问题。可使用版本号时间戳原子引用AtomicStampedReference
。
CAS底层原理?谈谈对Unsafe类的理解?
Unsafe类是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,基于Unsafe类可以直接操作特定内存的数据。存在于sun.misc包中,Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的所有方法都直接调用操作系统底层资源执行相应任务。
问题:我们知道i++是线程不安全的,那AtomicInteger.getAndIncrement()如何保证原子性?
- AtomicInteger类主要利用CAS+volatile和native方法来保证原子操作,从而避免synchronized的高开销,执行效率大为提升;调用Unsafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。
- JDK提供的CAS机制,在汇编层级会禁止变量两侧的指令优化,然后使用compxchg指令比较并更新变量值(原子性)。
CAS与自旋锁
CAS是实现自旋锁的基础,CAS利用CPU指令保证了操作的原子性,以达到锁的效果。自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁。好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
实现一个自旋锁,借鉴CAS思想:
1 |
|
19. 原子操作类
基本类型原子类:
- AtomicInteger 整型原子类
- AtomicBoolean 布尔型原子类
- AtomicLong 长整型原子类
数组类型原子类:
- AtomicIntegerArray 整型数组原子类
- AtomicLongrArray 长整型数组原子类
- AtomicReferenceArray 引用类型数组原子类
引用类型原子类:
- AtomicReference 引用类型原子类
- AtomicStampedReference 原子更新带有版本号的引用类型,解决修改过几次
- AtomicMarkableReference 原子更新带有标记的引用类型,解决是否修改过,将标记戳简化为true/false
对象的属性修改原子类:
AtomicIntegerFieldUpdater 原子更新对象中int类型字段的值
AtomicLongFieldUpdater 原子更新对象中Long类型字段的值
AtomicReferenceFieldUpdater 原子更新对象中引用类型字段的值
使用要求:更新的对象属性必须使用public volatile修饰符;因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。
原子操作增强类:
- DoubleAccumulator 一个或多个变量,它们一起保持运行double使用所提供的功能更新值
- DoubleAdder 一个或多个变量一起保持初始为零double总和
- LongAccumulator 一个或多个变量,一起保持使用提供的功能更新运行的值long ,提供了自定义的函数操作
- LongAdder 一个或多个变量一起维持初始为零long总和(重点),只能用来计算加法,且从0开始计算;sum()在并发情况下不保证返回精确值
热点商品点赞计算器,点赞数加加统计,不要求实时精确
1 |
|
和AtomicLong比,LongAdder为什么这么快?
- LongAdder是Striped64的子类;Striped64的基本结构里的base变量,类似于AtomicLong中全局的value;collide 扩容意向;cellsBusy 初始化cells或者cells扩容时需要获取锁,0无1有;casCellsBusy() 通过CAS操作修改cellsBusy的值,成功代表获取锁;NCPU 扩容时会用到cpu数量;getProbe() 获取当前线程的hash值;advanceProbe() 重置当前线程的hash值。
- cell是java.util.concurrent.atomic下Striped64的一个内部类,cells数组的长度初始默认值是2,扩容为原来的2倍
- LongAdder的基本思路就是分散热点,将value值分散到一个Cell数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作(减少乐观锁的重试次数),这样热点就被分散了,冲突的概率就小很多,如果要获取真正的long值,只要将各个槽中的变量值累加返回
- sum()会将所有的Cell数组中的value和base累加作为返回值
- 化整为零,分散热点,空间换时间
20. ThreadLocal
ThreadLocal是什么?能干吗?
ThreadLocal提供线程局部变量,每一个线程在访问ThreadLocal实例的时候(通过其get或set方法)都有自己的、独立初始化的变量副本。不和其他线程共享,从而避免了线程安全问题。withInitial(supplier)静态方法创建线程局部变量。
ThreadLocal中ThreadLocalMap的数据结构和关系
Thread.java ——> ThreadLocal.ThredLocalMap threadLocals = null
ThreadLocal.java ——> static class ThreadLocalMap
——> static class Entry extends WeakReference<ThreadLocal<?>>
ThreadLocalMap实际上就是一个以ThreadLocal实例为Key,任意对象为value的Entry对象;当我们为ThreadLocal变量赋值,实际上就是以当前ThreadLocal实例为Key,值为value的Entry往这个ThreadLocalMap中存放。ThreadLocal本身并不存储值。
ThreadLocal的key是弱引用,这是为什么?
关于引用:
- 强引用:对于强引用的对象,就算是出现了OOM也不会对该对象进行回收,死都不收,当一个对象被强引用变量引用时,它处于可达状态,是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到,JVM也不会回收。除非对象引用置为null,或者超过作用域,即变为不可达,此时可以回收。
- 软引用:当系统内存充足时,不会被回收,当系统内存不足时,他会被回收。
- 弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否足够,都会回收该对象占用的内存。
- 虚引用:虚引用必须和引用队列联合使用,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都有可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象。虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被finalize后,做某些事情的通知机制。虚引用被干掉,将会进入引用队列,在队列中发现有对象,则说明被GC过。
关于为什么用弱引用:
- 当方法执行完毕后,栈帧销毁,强引用tl也就没有了,但此时线程的ThreadLocalMap里某个entry的Key引用还指向这个对象,若这个Key是强引用,就会导致Key指向的ThreadLocal对象即key指向的对象不能被gc回收,造成内存泄露;
- 使用弱引用就可以使ThreadLocal对象在方法执行完毕后顺利被回收且entry的key引用指向为null,大概率会减少内存泄漏的问题。(还得考虑value不为null的问题)
ThreadLocal内存泄漏问题你知道吗?ThreadLocal中最后为什么要加remove方法?
- 不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄漏;
- 虽然弱引用,保证了Key指向的ThreadLocal对象能够被及时回收,但是v指向的value对象是需要ThreadLocalMap调用get、set时发现key为null时才会去回收整个entry、value,因此弱引用不能100%保证内存不泄露,我们要在不使用某个ThreadLocal对象后,手动调用remove方法来删除它。
- 都会通过expungeStaleEntry,cleanSomeSlots,replaceStaleEntry这三个方法回收键为null的Entry对象的值(即为具体实例)以及entry对象本身从而防止内存泄漏,属于安全加固的方法。
21. AbstractQueuedSynchronizer之AQS
AQS是什么
抽象的队列同步器:
- 是用来实现锁或者其他同步器组件的公共基础部分的抽象实现;
- 主要用于解决锁分配给“谁”的问题;
- 整体就是一个抽象的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态。
AQS为什么是JUC内容中最重要的基石?
- ReentrantLock、CountDownLatch、ReentrantReadWriteLock、Semaphore等等类的内部都有抽象的静态内部类Sync继承自AQS:abstract static class Sync extends AbstractQueuedSynchronizer{};
- 进一步理解锁和同步器的关系:锁,面向锁的使用者,定义了程序员和锁交互的使用层API,隐藏了实现细节,你调用即可;同步器,面向锁的实现者,Java并发大神DoungLee,提出了统一规范并简化了锁的实现,将其抽象出来,屏蔽了同步状态管理、同步队列的管理和维护、阻塞线程排队和通知、唤醒机制等,是一切锁和同步组件实现的。
AQS的排队等候机制
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS同步队列的抽象表现。它将要请求共享资源的线程及自身的等待状态封装成队列的节点对象(Node),通过CAS、自旋以及LockSupport.park()的方式,维护着state变量的状态,使其达到同步的状态。
- AQS内部体系架构—-内部类Node:Node的等待状态waitState成员变量;
AQS源码深度分析
- 公平锁和非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()—–公平锁加锁时判断等待队列中是否存在有效节点的方法。
- lock() ——> acquire(1) 第二个线程及后续线程抢占
- if(!tryAcquire(arg)&&acquireQueued(addWaiter(Node.EXCLUSIVE),arg))
- addWaiter若链表没初始化就先初始化,否则入队enq(Node);compareAndSetHead和compareAndSetTail;在双向链表中,第一个节点为虚节点(也叫做哨兵节点),其实不存储任何信息,只是占位。真正的第一个有数据的节点,是从第二个节点开始的。
- acquireQueued中自旋tryAcquire、前置节点状态得为SIGNAL、阻塞当前节点parkAndCheckInterrupt(),里面调用的是LockSupport.park(this)。
- unlock() ——> release(1) ——> tryRelease(arg) 释放锁、修改资源的占有状态 ——> unparkSuccessor(h) 唤醒头结点的后置结点,里面调用的是LockSupport.unpark(s.thread)。
22. 读写锁ReentrantReadWriteLock
是什么?特点有哪些?
- 一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程;
- 读读共存,读写互斥,写写互斥;
- 只有在读多写少情景之下,读写锁才具有较高的性能体现。
读写锁中的锁降级
- 将写锁降级为读锁 —— 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级为读锁;
- 如果一个线程持有了写锁,在没有释放写锁的情况下,它还可以继续获得读锁;
- 从读锁升级到写锁是不可能的。
有没有比读写锁更快的锁?StampedLock邮戳锁
是什么:
- StampedLock是JDK1.8中新增的一个读写锁,也是对JDK1.5中的读写锁ReentrantReadWriteLock的优化;
- stamp 代表了锁的状态。当stamp返回零时,表示线程获取锁失败,并且当释放锁或者转换锁的时候,都要传入最初获取的stamp值;
- ReentrantReadWriteLock实现了读写分离,当前有可能会一直存在读锁,而无法获得写锁,锁饥饿;StampedLock类采取乐观获取锁,其他线程尝试获取写锁时不会被阻塞,在获取乐观读锁后,还需要对结果进行校验。
特点:
- 所有获取锁的方法,都返回一个邮戳,stamp为零表示失败,其余都表示成功;
- 所有释放锁的方法,都需要一个邮戳,这个stamp必须是和成功获取锁时得到的stamp一致;
- StampedLock是不可重入的,危险(如果一个线程已经持有了写锁,在去获取写锁的话会造成死锁)
- 三种访问模式:Reading(读模式悲观)、Writing(写模式)、Optimistic reading(乐观读模式)
- 读的时候也可以写,如果读的时候被写了,那就重新读一次。对短的只读代码段,使用乐观模式通常可以减少争用并提高吞吐量
- StampedLock 的悲观读锁和写锁都不支持条件变量(Condition),这个也需要注意;
- 使用 StampedLock一定不要调用中断操作,即不要调用interrupt()方法。
23. Java对象内存布局和对象头
Object object = new Object()谈谈你对这句话的理解?
- 位置所在——–>JVM堆->新生区->伊甸园区
- 构成布局——–>对象头+实例数据+对齐填充
- 对象头(在64位系统中,Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节)
- 对象标记(Mark Word)默认存储对象的HashCode、分代年龄和锁标志等信息。
- 类元信息(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象哪个类的实例。
- 实例数据:存放类的属性(Field)数据信息,包括父类的属性信息。
- 对齐填充:虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按8字节补充对齐。
- ClassLayout.parseInstance(new Object()).toPrintable()
有关压缩指针
- Java -XX:+PrintCommandLineFlags -version 查看当前虚拟机信息
- 默认开启压缩指针,开启后将上述类型指针压缩为4字节,以节约空间
- 手动关闭压缩指针: -XX: -UseCompressedClassPointers
24. Synchronized与锁升级
- Java5之前,只有Synchronized,这个是操作系统级别的重量级操作,用户态和内核态之间的频繁转换,假如锁的竞争比较激烈的话,性能下降;
- Java6之后为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。
锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位。
- 偏向锁:MarkWord存储的是偏向的线程ID;
- 轻量锁:MarkWord存储的是指向线程栈中Lock Record的指针;
- 重量锁:MarkWord存储的是指向堆中的monitor对象(系统互斥量指针)。
偏向锁
单线程竞争,当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁。当有另外一个线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为轻量级锁,使用的是等到竞争出现才释放锁的机制。竞争线程尝试CAS更新对象头失败,会等到全局安全点(此时不会执行任何代码)撤销偏向锁,同时检查持有偏向锁的线程是否还在执行:
1. 第一个线程正在执行Synchronized方法(处于同步块),它还没有执行完,其他线程来抢夺,该偏向锁会被取消掉并出现锁升级,此时轻量级锁由原来持有偏向锁的线程持有,继续执行同步代码块,而正在竞争的线程会自动进入自旋等待获得该轻量级锁
2. 第一个线程执行完Synchronized(退出同步块),则将对象头设置为无所状态并撤销偏向锁,重新偏向。
Java15以后逐步废弃偏向锁,需要手动开启——->维护成本高。
轻量级锁
多线程竞争,但是任意时候最多只有一个线程竞争;
有线程来参与锁的竞争,但是获取锁的冲突时间极短———->本质是自旋锁CAS;
自旋一定程度和次数(Java6是默认10次;Java8 之后是自适应自旋锁——意味着自旋的次数不是固定不变的);
轻量锁和偏向锁的区别:
- 争夺轻量锁失败时,自旋尝试抢占锁;
- 轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁。
重量级锁
有大量线程参与锁的竞争,冲突性很高;
Java中synchronized的重量级锁,是基于进入和退出Monitor对象实现的。在编译时会将同步块的开始位置插入monitor enter指令,在结東位置插入monitor exit指令。
当线程执行到monitor enter指令时,会尝试获取对象所对应的Monitor所有权,如果获取到了,即获取到了锁,会在Monitor的owner中存放当前线程的id,这样它将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个Monitor。
锁升级后,hashcode去哪儿了?
当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,有收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现重,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为01)下的Mark Word,其中存储了原来的哈希码。升级为轻量级锁时,JVM会在当前线程的栈帧中创建一个锁记录(Lock Record)空间,用于存储锁对象的Mark Word拷贝,该拷贝中可以包含identity hash code,释放锁后会将这些信息写回对象头。
锁消除
从JIT(Just In Time Compiler 即时编译器)角度看想相当于无视他,synchronized(o)不存在了;这个锁对象并没有被共用扩散到其他线程使用;极端的说就是根本没有加锁对象的底层机器码,消除了锁的使用。
1 |
|
锁粗化
假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器会把这几个synchronized块合并为一个大块;加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提高了性能。
1 |
|
Original link: http://example.com/2023/04/10/offer-JUC/
Copyright Notice: 转载请注明出处.