第1章 线程基础
1.为什么多线程及其重要?
硬件层面:摩尔定律失效。
- 摩尔定律:价格不变,集成电路上可容纳的元器件的数目约每隔18-24个月便会增加一倍,性能也将提升一倍。也就是1美元能买到的电脑性能,将每隔18个月翻一倍以上。
- 可是从2003年开始CPU主频已经不再翻倍,而是采用多核而不是更快的主频。摩尔定律失效。
- 在主频不在翻倍的今天,想要提高程序的运行速度就要用到并行或并发编程。
软件层面:高并发系统,异步+回调等生产需求。
2.Java多线程相关概念
进程:是程序运行的基本单位,是系统进行资源分配的独立单位。
线程:是程序运行的最小单位,在同一个进程内可以执行多个任务,每个任务就可以看做一个线程。
管程:Monitor(监视器),就是锁。
- 就是一种同步机制,保证同一时间只有一个线程可以访问被保护的数据和代码。
- JVM中同步(锁)就是基于进入和推出Monitor对象来实现的,每个对象都会有一个Monitor对象。
- Monitor对象会和Java对象一同创建并销毁,它底层是由C++来实现的。
3.用户线程和守护线程
通过线程的daemon
属性就能知道该Java线程是用户线程还是守护线程,true表示守护线程。
用户线程:是系统工作线程,它会完成程序需要的所有业务操作。
守护线程:是一种特殊线程,在后台默默完成一些系统性服务,比如垃圾回收线程。
重点:可以手动通过线程的daemon
属性设置为守护线程,但必须要在start()
方法前设置。当所有用户线程执行完,不管守护线程是否执行完,都会强制退出。
第2章 CompletableFuture
01 FutureTask
Thread和Runnable创建线程没有返回值,为了解决出来第三中创建线程的方法Callable
接口,但Thread并没有构造提供,所以需要同FutureTask类的构造传入Callable,FutureTask实现了Runnable接口。
FutureTask<String> task = new FutureTask<>(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "执行了");
Thread.sleep(5000);
return "老师我帮你买到水了";
});
new Thread(task).start();
FutureTask提供了两个获取Callable返回值的两个方法:
public V get();
//可以设置超时时间
public V get(long timeout, TimeUnit unit);//传入2,TimeUnit.SECONDS后,表示2秒以后若还没获取到就抛异常
缺点:get()
会导致主线程阻塞,CompletableFuture就能解决。相对于FutureTask的加强版。
02 CompletableFuture
1.CompletableFuture的继承结构
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {}
FutureTask实现了Future,所以CompletableFuture相对于继承了FutureTask,又在此基础上增加CompletionStage新功能。
2.核心四个静态方法,来创建一个异步对象:
//Runnable没有返回值。
public static CompletableFuture<Void> runAsync(Runnable runnable);
public static CompletableFuture<Void> runAsync(Runnable runnable,Executor executor);
//Supplier有返回值。也是函数式接口
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier);
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier,Executor executor);
创建好了,直接通过get()
执行该线程,并获取其返回值。没有就返回null。
Executor参数说明:如果没指定就用默认ForkJoinPool.commonPool()
作为它的线程池执行异步代码。否则就用自定义的。
3.CompletableFuture实现异步调用
如果直接通过get()
执行该线程,则还是会跟FutureTask一样会阻塞。所以它提供了一些异步函数来实现:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println(Thread.currentThread().getName() + "\t" + "执行了");
Thread.sleep(2000);
return 1;
}).thenApply(f -> f + 1) //f表示上一个的输入,在做些操作返回给下一层
.whenComplete((v, e) -> { //v表示上一个的输入,e表示是否抛异常
if (e == null) { //null表示没抛,则这里执行异步函数内容
System.out.println("异步函数完成了 " + v);
}
}).exceptionally(e -> { //如果上述抛异常了,则会来到这里处理异常相当于catch操作。
e.printStackTrace();
return null;
});
System.out.println("继续上课");
//这里如果不休眠则主线程会结束,守护线程也会强制结束。所以也就等不到异步函数完成了。
Thread.sleep(3000);
这里就不要调用get()执行该线程了,whenComplete
方法就给你执行了。如果需求就要阻塞,建议用join()
代替get(),两者功能是一样的,唯一的区别就是join()不会抛异常。
03 常用方法
1.获取结果和触发计算
public T get()
:执行并返回结果,会阻塞,但如果多个CompletableFuture则会并行计算。
public T join()
:跟get()一样,唯一区别就是不用异常处理。(推荐)
public T getNow(T value)
:没有计算完返回该value,否则正常返回结果。
public boolean complete(T value)
:没有计算完返回该true,在次执行get()则会返回该value。
2.对计算结果进行处理
thenApply(Function)
:把上一个结果集给到下一个输入,通过函数式接口处理并返回。
- Function:有一个输入和一个输出。
handle(BiFunction)
:跟thenApply一样,区别只是会无视异常,继续往下执行。
- BiFunction:有两个输入和一个输出。第二个输入就是异常对象,可以忽略。
3.对计算结果进行消费
thenAccept(Consumer)
:跟thenApply差不多,只是没有返回值,而且会处理该任务。
- Consumer:有一个输入,没有输出。
4.对计算速度进行选用
applyToEither(CompletionStage,Function)
:比较两个CompletableFuture,谁快用谁。
- CompletionStage:传入第二个比较的CompletableFuture。
- Function:一个输入和一个输出。输入是最快返回的任务结果集。输出是返回啥结果集。
5.对计算结果进行合并
thenCombine(CompletionStage,BiFunction)
:返回两个结果给集,并合并返回。
- CompletionStage:传入另一个CompletableFuture。
- BiFunction:两个输入和一个输出。两个输入是两个结果集,输出合并以后的结果集。
第3章 锁
01 乐观和悲观锁
悲观锁:适合写操作多的场景,保证数据正确性。显示的加锁,导致线程阻塞。(sync和Lock)
乐观锁:适合读操作的的场景,不会阻塞,不会加锁,只是一种思想,导致读性能大幅提升。
- 实现方式:采用version版本号机制,CAS(比较并替换)算法实现。
02 sync底层字节码
1.sync同步代码块
public void m1(){
synchronized(object){
System.out.println("------m1");
}
}
public void m1(){
0 aload_0
1 getfield #7 <com/it/day3/LockByteCodeDemo.object : Ljava/lang/Object;>
4 dup
5 astore_1
6 monitorenter //管程进入
7 getstatic #13 <java/lang/System.out : Ljava/io/PrintStream;>
10 ldc #19 <------m1>
12 invokevirtual #21 <java/io/PrintStream.println : (Ljava/lang/String;)V>
15 aload_1
16 monitorexit //管程释放
17 goto 25 (+8)
20 astore_2
21 aload_1
22 monitorexit //管程释放
23 aload_2
24 athrow
25 return
}
为什么是1:2?释放两次?
- 第一个monitorexit保证正常释放。
- 第二个monitorexit是要确保程序出异常时也能被释放。否则sync可以把jvm一种锁住。
2.sync同步代码块(异常)
public void m1(){
synchronized(object){
System.out.println("------m1");
throw new RuntimeException("----RuntimeException");
}
}
public void m1(){
0 aload_0
1 getfield #7 <com/it/day3/LockByteCodeDemo.object : Ljava/lang/Object;>
4 dup
5 astore_1
6 monitorenter //管程进入
7 getstatic #13 <java/lang/System.out : Ljava/io/PrintStream;>
10 ldc #19 <------m1>
12 invokevirtual #21 <java/io/PrintStream.println : (Ljava/lang/String;)V>
15 new #27 <java/lang/RuntimeException>
18 dup
19 ldc #29 <----RuntimeException>
21 invokespecial #31 <java/lang/RuntimeException.<init> : (Ljava/lang/String;)V>
24 athrow //正常抛Runtime异常
25 astore_2
26 aload_1
27 monitorexit //管程释放
28 aload_2
29 athrow //底层要告诉系统这是非正常中断的一种释放锁
}
这次为啥是1:1?为啥有两个athrow?
因为这次手动抛异常了,最后一个athrow要告诉jvm这是一种是非正常中断的一种释放锁,所以就替代了一个monitorexit。
3.sync同步方法
调用指令将会检查方法的ACC_SYNCHRONIZED
访问标志是否被设置。
如果设置了,执行线程会将先持有monitor然后在执行方法,最后在方法完成(无论是正常还是非正常完成)时释放monitor。
4.sync静态同步方法
ACC_STATIC
,ACC_SYNCHRONIZED
访问标志区分该方法是否静态同步方法。
5.sync锁的是什么?
在HotSpot虚拟机中,monitor采用ObjectMonitor实现
(ObjectMonitor.java–>ObjectMonitor.cpp–>ObjectMonitor.hpp)
每个对象天生都带着一个对象监视器monitor。
//ObjectMonitor.hpp 的构造方法
ObjectMonitor() {
_header = NULL;
_count = 0; //用来记录该线程获取锁的次数
_waiters = 0,
_recursions = 0; //锁的重入次数
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //存放处于wait状态的线程队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //存放处于等待锁block状态的线程队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
_owner:记录当前哪个对象在持有我。
_WaitSet:存放处于等待状态的线程队列。
_EntryList:存放处于等待锁阻塞状态的线程队列。
_recursions和_count:记录可重入锁的重入次数和该线程获取锁的次数。
03 公平和非公平锁
1.演示买票案例
public class SaleTicketDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(() -> { for (int i = 0; i < 55; i++) ticket.sale(); }, "t1").start();
new Thread(() -> { for (int i = 0; i < 55; i++) ticket.sale(); }, "t2").start();
new Thread(() -> { for (int i = 0; i < 55; i++) ticket.sale(); }, "t3").start();
new Thread(() -> { for (int i = 0; i < 55; i++) ticket.sale(); }, "t4").start();
new Thread(() -> { for (int i = 0; i < 55; i++) ticket.sale(); }, "t5").start();
}
}
class Ticket{
private int sumber = 50;
private Lock lock = new ReentrantLock(true); //默认是非公平,加true为公平
public void sale(){
lock.lock();
try {
if (sumber > 0){
System.out.println(Thread.currentThread().getName() + "\t卖出第 " + (sumber--) + " 张票 \t"+"还剩:"+sumber);
}
} finally {
lock.unlock();
}
}
}
公平锁:在底层源码实现的时候会通过!hasQueuedThreads()
方法做一个判断。
!hasQueuedThreads()
:底层基于AQS实现的。是通过链表维护的,判断是否有前驱节点。(性能较低)
非公平锁:它是直接拿到当前线程,直接执行。少了判断这个逻辑,所以性能比较快。
2.为什么会有公平/非公平锁的设计?为什么默认非公平?
- 恢复挂起的线程到真正锁的获取还是有时间差的,从开发着的角度看是微乎其微,但从CUP角度看,还是很明显的。所以非公平锁能充分利用CPU的时间片,尽量减少CPU空闲状态时间。
- 使用多线程是重点考量点是线程切换开销,当采用非公平锁时,当1个线程请求锁获取同步状态,然后释放同步状态,因为不需要考虑是否有前驱节点,所以刚释放的线程在此刻再次获取同步状态的概率就变得非常大,所以就减少了线程开销。
3.公平锁会有什么问题?
公平锁保证了排队的公平性,非公平锁忽视这个规则,所以可能导致排队时间的过长,也没有机会获取到锁,这就是"锁饥饿"现象。所以公平锁解决了这个现象。
4.各自适用场景?
如果为了更高的吞吐量,非公平锁比较合适,因为节省了很多线程切换时间,吞吐量自然就上去了。否则就用公平锁。
04 可重入锁
可重入锁又名递归锁
是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。
如果是1个有 synchronized 修饰的递归调用方法,程序第2次进入被自己阻塞了岂不是天大的笑话。
所以Java中ReentrantLock
和synchronized
都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。
1.“可重入锁”四个字分开解释:
可:可以。
重:再次。
入:进入。
锁:同步锁。
进入什么?进入同步域(即同步代码块/方法或显示锁锁定的代码)
一句话:一个线程中的多个同步域可以获取同一把锁,持有这把同步锁可以再次进入。
2.synchronized的重入的实现原理
每个锁对象拥有一个锁计数器_count
和一个指向持有该锁的线程的指针。
当执行monitorenter时,如果目标锁对象的_count为零,说明它没有被其他线程所持有,JVM会将该锁对象的持有线程设置为当前线程,并且将其_count加1。
在目标锁对象的计数器不为零的情况下,如果锁对象的持有线程是当前线程,那么 JVM可以将其_count加1,否则需要等待,直至持有线程释放该锁。
当执行monitorexit时,JVM则需将锁对象的_count减1。_count为零代表锁已被释放。
05 其它锁
1.写锁(独占锁)/读锁(共享锁)
源码深入分析见后续第13章
2.自旋锁SpinLock
源码深入分析见后续第7章
3.无锁—>独占锁—>读写锁—>邮戳锁
有没有比读写锁更快的锁?StampedLock;源码深入分析见后续第13章
4.无锁—>偏向锁—>轻量锁—>重量锁
源码深入分析见后续第11章
第4章 LockSupport与线程中断
01 线程中断机制
一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。
在Java中没有办法立即停止一条线程,然而停止线程却显得尤为重要,如取消一个耗时操作。
因此,Java提供了一种用于停止线程的机制——中断。
中断只是一种协作机制,Java没有给中断增加任何语法,中断的过程完全需要程序员自己实现。
1.中断相关API方法
void interrupt()
:修改线程中断标识设为true。不会停止线程。(默认是false)
- 如果当前线程处于
活跃状态
,则会将中断标识设为true,仅此而已。 - 如果当前线程处于阻塞状态(如:sleep,wait,join等),则该线程会立即退出阻塞状态,并抛一个
InterruptedException
异常。不会修改中断标识。
boolean isInterrupted()
:返回当前线程中断状态。
static boolean interrupted()
:先返回当前线程中断状态,在将该线程中断状态设置为false。
2.中断相关API方法源码分析
//这都是Thread类中的属性方法
//中断标识位
private volatile boolean interrupted;
public void interrupt() {
if (this != Thread.currentThread()) {
checkAccess();
// thread may be blocked in an I/O operation
synchronized (blockerLock) {
Interruptible b = blocker;
if (b != null) {
interrupted = true;
interrupt0(); // 主要就是这调了一个native方法
b.interrupt(this);
return;
}
}
}
interrupted = true;
// inform VM of interrupt
interrupt0();
}
public boolean isInterrupted() {
return interrupted;
}
public static boolean interrupted() {
Thread t = currentThread();
boolean interrupted = t.interrupted;
if (interrupted) {
t.interrupted = false;
clearInterruptEvent(); //native方法
}
return interrupted;
}
02 线程等待唤醒
1.线程等待唤醒的3种方法
-
Object中的
wait()
等待、notify()
唤醒。必须在sync锁里面,而且要遵守先后顺序,不能先唤醒。 -
Condition接口中的
await()
等待、signal()
唤醒。必须在lock锁里面,其它限制条件跟上面的一样。 -
LockSupport类中的
park()
等待、unpark()
唤醒。(重点)
2.Object和Condition使用的限制条件
- 线程先要获得并持有锁,必须在锁块(synchronized/lock)中。
- 必须要先等待后唤醒,线程才能够被唤醒。
LockSupport上述两个问题都能解决。
03 LockSupport
LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。
LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能, 每个线程都有一个许可(permit),permit只有两个值1和0,默认是0。
可以把许可看成是一种(0,1)信号量(Semaphore),但与 Semaphore 不同的是,许可的累加上限是1。
API
static void park()
:等待。
static void unpark(Thread t)
:唤醒。
第5章 JMM
01 Java内存模型
1.Java内存模型 Java Memory Model,简称JMM
是一种抽象的概念并不真实存在它仅仅描述的是一组约定或规范
,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量(RAM)的写入何时以及如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性和有序性展开的。
2.作用
- 通过JMM来实现线程和主内存之间的抽象关系。(就相当于原有OS三级缓存操作)
- 屏蔽各个硬件平台和OS的内存访问差异以实现让Java程序在各种平台下都能达到一致的内存访问效果。
02 三大特性
1.原子性
指一个操作是不可中断的,即多线程环境下,操作不能被其他线程干扰。
2.可见性
指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存(RAM)中。
Java中普通的共享变量不保证可见性,因为数据修改被写入内存时机是不确定的,多线程并发下很可能出现“脏读”,所以每个线程都有独立的工作内存,工作内存中的数据来自于对RAM副本的拷贝,线程对变量的所以操作(读,写等)都只能在自己的工作内存中进行,而不能修改RAM中的数据。所以不同线程之间也无法访问对方工作内存,只能通过主内存RAM去完成。
3.有序性
对于一个线程而言,我们通常认为代码总是从上往下依次有序执行,实则为否。
为了提高性能,编译器和处理器通常会对指令序列进行重排序。
指令重排:可以保证串行语义一致,但没有义务保证多线程间的语义也一致,即可能产生"脏读"。
简单的说,两行以上不相关的代码执行时,不见得是从上往下顺序执行,执行顺序会被优化。
单线程环境里要确保最终执行结果和代码顺序结果一致。
CPU在进行重排时,会考虑指令之间的数据依赖性。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中的变量能否保证一致性是无法确定的。
03 happens-before
如果JMM中所有的有序性都仅靠volatile和synchronized来完成,那么会有很多操作变的非常啰嗦。
我们在写Java代码时并没有觉察到这一点,就因为JMM里有一个"先行发生"(happens-before)原则规定的。
它可以判断数据是否存在竞争,线程是否安全的非常有用的手段。通过这个原则可以解决并发环境下两个操作之间是否存在冲突的所以问题,从而不需要陷入JMM苦涩难懂的底层编译原理之中。
1.happens-before的总原则:
- 如果两个操作之间存在happens-before,那么前一个操作执行结果对后一个操作可见。并保证顺序性。
- 两个操作之间存在happens-before关系,并不一定要按照happens-before原则指定的顺序来执行。如果重排后的执行结果与按happens-before关系来执行的结果一致,那么这种重排并不非法。
2.happens-before的8条规则:
- 次序规则:一个线程内,按代码顺序,写在前面的操作先行发生于写在后面的操作。
- 就是前一个操作把变量x赋为1,那么后一个操作肯定能知道x已经变成了1。
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作。
- 这里的”后面“,指的是时间上的先后。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
- 前面的写对后面的读是可见的,这里的”后面“同样是指时间上的先后。
- 传递规则:如果操作A先行于操作B,操作B又先行于操作C,则可以得出操作A先行发生于操作C。
- 线程启动:Thread::start()方法先行发生于此线程的每一个动作。
- 线程中断:对线程interrupt()的调用先行发生于被中断线程的代码检测到中断事件的发生。
- 线程终止:线程中所以操作都先行发生于终止检测。join()和isAlive()判断是否结束和是否已经终止执行。
- 对象终结:一个对象的初始化(构造方法执行结束)完成先行于它的finalize()方法的开始。
第6章 volatile
01 volatile特点
可以保证JMM规范中的可见性和有序性。如何保证的呢?
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量立刻刷新会RAM中。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从RAM中读取共享变量。
02 内存屏障
volatile之所以可以实现可见性和有序性就是靠的内存屏障指令。
定义:又叫内存栅栏,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所以读写操作都执行后才可以开始执行点之后的操作。避免代码重排序。
内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,这些指令就能控制指令重排序和内存可见性。
具体指令源码分析:Unsafe.java–>Unsafe.cpp–>OrderAccess.hpp
//Unsafe.java
//读屏障,确保该屏障之前所有读操作都能完成。防止读操作被重排序到屏障之后。
@IntrinsicCandidate
public native void loadFence(); //场景:确保读取共享变量时能看到其他线程的最新写入。
//写屏障,确保在该屏障之前的所以写操作都完成。防止写操作被重排序到屏障之前。
@IntrinsicCandidate
public native void storeFence();//场景:确保对共享变量的修改对其他线程可见。
//全屏障,确保在屏障之前的所以读写操作都完成。防止读写操作跨越屏障重排序。
@IntrinsicCandidate
public native void fullFence();//场景:实现严格的顺序一致性内存模型。
//OrderAccess.hpp(Unsafe.cpp底层就是调的这些方法)(面试重点,把这些单词记下)
static void loadload(); //防止两个读操作被重排序
static void storestore(); //防止两个写操作被重排序
static void loadstore(); //防止读操作和后续写操作被重排序
static void storeload(); //防止写操作和后续读操作被重排序
//OrderAccess.hpp四个方法举例
//1.loadload():防止两个读操作被重排序
int x = sharedVar; // Load 1
OrderAccess::loadload(); // 确保 Load 1 先于 Load 2(保证Load 1已经刷新到RAM)
int y = sharedVar; // Load 2
//2.storestore():防止两个写操作被重排序
sharedVar1 = 42; // Store 1
OrderAccess::storestore(); // 确保 Store 1 先于 Store 2
sharedVar2 = 43; // Store 2
//3.loadstore():防止读操作和后续写操作被重排序
int x = sharedVar; // Load
OrderAccess::loadstore(); // 确保 Load 先于 Store
sharedVar = x + 1; // Store
//4.storeload():防止写操作和后续读操作被重排序
sharedVar = 42; // Store
OrderAccess::storeload(); // 确保 Store 先于 Load
int x = sharedVar; // Load
03 happens-before之volatile变量规则
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile读 | 第二个操作:volatile写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile写 | 可以重排 | 不可以重排 | 不可以重排 |
- 当第一个为volatile读时,无论第二个是什么都不能重排。这是为了保证读之后的操作不会被重排到之前。
- 当第二个为volatile写时,无论第一个是什么都不能重排。这是为了保证写之前的操作不会被重排到之后。
04 JMM栅栏策略
JMM将内存屏障插入策略分为4种:
写:在每个volatile写操作的前面和后面分别插入一个StoreStore和StoreLoad屏障。
读:在每个volatile读操作的后面插入一个LoadLoad和LoadStore屏障。
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 保证load1的读取操作在load2之前执行 |
StoreStore | Store1; StoreStore; Store2 | 在store2写操作执行前保证store1的写已经刷新进RAM |
LoadStore | Load1; LoadStore; Store2 | 在stroe2写操作执行前保证Load1的读操作已结束 |
StoreLoad | Store1; StoreLoad; Load2 | 保证store1写操作已刷新到RAM之后load2读操作才能执行 |
05 volatile保证可见
保证不同线程对这个变量进行操作时的可见性,即变量一旦被改变所有线程立即可见。
volatile变量的读写过程:(底层OS源码分析)
JMM中定义的8种工作内存与RAM之间的原子操作:
read(读取)—>load(加载)—>use(使用)—>assign(赋值)—>store(存储)—write(写入)—>lock(锁定)—>unlock(解锁)
read:作用于主内存,将变量的值从主内存传输到工作内存。
load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载。
use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量
由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:
lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用。
06 volatile没有原子
volatile变量的复合操作(如i++)不具有原子性。既然一修改就是可见,为啥还不能保证原子性?
在use(使用)一个变量时,就必须load(载入),在载入时就必须从主内存read(读取),这样解决了读的可见性。
在assign(赋值)时,就必须store(存储),存储后write(写入),这样解决了写的可见性。
但是在交给JVM和从JVM中拿到这之间是不可见的,也就是use和assign之间不可见,所有就保证不了整体的原子性。只能保证单一read-load-use
或assign-store-write
的原子性。
所以其它线程就会在变量交给CPU的那个空闲来侵入。
这个特性就导致了volatile变量不适合参与到依赖当前值的运算,如i = i + 1; i++;之类的。
那么依靠可见性的特点volatile可以用在哪些地方呢? 通常用做保存某个状态的boolean值or int值。
07 指令重排
volatile保证有序性,就是同栅栏和happens-before保证的。
08 适用场景
单一赋值可以,但是含复合运算赋值不可以(i++之类)
-
volatile int a = 10
-
volatile boolean flag = false
状态标志,判断业务是否结束。
开销较低的读,写锁策略。
单例模式的双重检查锁(DCL)策略。
第7章 CAS
01 是什么
1.没有CAS之前
- 多线程环境下想保证线程安全的原子性,只能通过加锁。
2.CAS是什么?
CAS(compare and swap的缩写,翻译为比较并交换),实现并发算法时常用到的一种技术。它包含三个操作数——内存位置、预期原值及更新值。
执行CAS操作时会将内存位置的值与预期原值比较;如果匹配就更新该位置为新值,不匹配就不做任何操作。
3.硬件级别保证
CAS是JDK提供的非阻塞原子性操作,通过硬件保证了比较-更新
的原子性。
CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致,Unsafe.java提供的CAS方法(如compareAndSwapXXX)底层实现就是CPU的cmpxchg。
执行cmpxchg指令时,CPU会判断当前系统是否为多核,如果是就给总线加锁,只有一个线程会对加锁成功,成功之后就会执行cas操作,也就是说CAS的原子性是CPU实现的,其实在这一点上还是有排他锁的,只是相对于sync这种重量级锁还说,这里的排他时间要短的多,所以在多线程情况下性能会比较好。
4.CASDemo代码实现
//构造中传入的5就是内存位置
AtomicInteger atomicInteger = new AtomicInteger(5);
//compareAndSet入参就是预期原值和更新值
System.out.println(atomicInteger.compareAndSet(5, 2020)+"\t"+atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(5, 1024)+"\t"+atomicInteger.get());
02 底层原理
1.UnSafe
通过compareAndSet()源码可知,底层调的是Unsafe.java
中的compareAndSetInt()是native修饰的。
UnSafe是CAS的核心类,由于Java方法无法之间访问底层系统,需要通过本地(native)方法来访问,UnSafe相当于一个后门,基于该类可以直接操作特定内存的数据。它在sun.misc
包中,其内部方法操作可以向C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。
注意Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。
2.atomicInteger.getAndIncrement()
我们知道i++线程不安全,那么它getAndIncrement()就利用的CAS的思想解决了i++线程不安全问题。
AtomicInteger类主要利用 CAS + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。
//AtomicInteger.java
private static final Unsafe U = Unsafe.getUnsafe(); //单例的(饿汉式)
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
//Unsafe.java
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta)); //不相等就自旋
return v;
}
CAS它是一条并发原语,体现在Unsafe类中。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。原语属性OS用语范畴,有若干条指令组成,用于完成某个功能的过程,并且原语执行必须是连续的,在执行过程中不能被中断,也就是CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
3.compareAndSetInt()底层cpp方法分析
atomicInteger.compareAndSetInt()底层是调的Unsafe.java中的compareAndSetInt()被native修饰了,位于Unsafe.cpp中
//Unsafe.cpp
//compareAndSetInt方法实现
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
// 先想办法拿到变量value在内存中的地址,根据偏移量valueOffset,计算 value 的地址
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
// 调用 Atomic 中的函数 cmpxchg来进行比较交换,其中参数x是即将更新的值,参数e是原内存的值
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
//cmpxchg方法实现
unsigned Atomic::cmpxchg(unsigned int exchange_value,volatile unsigned int* dest, unsigned int compare_value) {
assert(sizeof(unsigned int) == sizeof(jint), "more work to do");
/*
* 根据操作系统类型调用不同平台下的重载函数,这个在预编译期间编译器会决定调用哪个平台下的重载函数*/
return (unsigned int)Atomic::cmpxchg((jint)exchange_value, (volatile jint*)dest, (jint)compare_value);
}
//如果是win10就会调如下重载函数(汇编指令)
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
//判断是否是多核CPU
int mp = os::is_MP();
__asm {
//三个move指令表示的是将后面的值移动到前面的寄存器上
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
//CPU原语级别,CPU触发
LOCK_IF_MP(mp)
//比较并交换指令
//cmpxchg: 即“比较并交换”指令
//dword: 全称是 double word 表示两个字,一共四个字节
//ptr: 全称是 pointer,与前面的 dword 连起来使用,表明访问的内存单元是一个双字单元
//将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,
//如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中
cmpxchg dword ptr [edx], ecx
}
}
实现方式是基于硬件平台的汇编指令,在intel的CPU中(X86机器上),使用的是汇编指令cmpxchg指令。
核心思想就是:比较要更新变量的值V和预期值E(compare),相等才会将V的值设为新值N(swap)如果不相等自旋再来。
03 原子引用
有AtomicInteger原子整形,是否有其它原子类型?有,AtomicReference
原子引用可以引用各种类型。
User zs = new User("张三", 18);
User ls = new User("李四", 20);
AtomicReference<User> reference = new AtomicReference<>();
reference.set(zs);
System.out.println(reference.compareAndSet(zs, ls) + "\t" + reference.get());
System.out.println(reference.compareAndSet(zs, ls) + "\t" + reference.get());
04 自旋锁
自旋锁(spinlock),Unsafe.java中的getAndAddInt()就用了自旋的思想。
是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,当线程发现锁被占用时,会不断循环判断锁的状态,直到获取。这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void MyLock(){
System.out.println(Thread.currentThread().getName() + "\t-----线程进来了");
//如果当前线程不为null就自旋,直到为null为止
while (!atomicReference.compareAndSet(null, Thread.currentThread())) {
}
System.out.println(Thread.currentThread().getName() + "\t持有锁成功");
}
public void MyUnLock(){
atomicReference.compareAndSet(Thread.currentThread(), null);
System.out.println(Thread.currentThread().getName() + "\t释放锁成功");
}
05 CAS缺点
1.循环时间长开销很大
getAndAddInt()执行时,有个do while,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
2.ABA问题?
CAS会导致“ABA问题”。
假如一个线程1从内存中取出A,这时另外一个线程2也从内存中取出A,并且线程2又将值变成了B,然后线程2又将值变成A,这时线程1在进行CAS操作时发现内存中还是A,然后线程1操作成功。
尽管线程1的CAS操作成功,但是不代表这个过程就是没有问题的。
原子类中的AtomicStampedReference
就能解决,底层引入了version版本号的机制,在CAS时会判断版本号。
//构造传入初始值和版本号
AtomicStampedReference<Integer> asr = new AtomicStampedReference<>(100,1);
//获取当前版本号
int stamp = asr.getStamp();
//预期值、修改后的值、预期版本号,修改后的版本号
asr.compareAndSet(100, 101, stamp, stamp + 1);
第8章 原子操作类
原子类就是java.util.concurrent.atomic
包下的所有类
01 基本类型原子类
AtomicInteger
、AtomicBoolean
、AtomicLong
;
1.常用API
int get()
:获取当前值int getAndSet(int newValue)
:获取当前值,并设置新值int getAndIncrement()
:获取当前值,并自增int getAndDecrement()
:获取当前值,并自减int getAndAdd(int delta)
:获取当前值,并加上预期值boolean compareAndSet(int expect, int update)
:如果输入的值等于预期值,则以CAS更新值
2.CountDownLatch
CountDownLatch,这个工具类可以代替main线程等待时间。
CountDownLatch countDownLatch = new CountDownLatch(50);
countDownLatch.countDown(); //这个要执行50次(一般放到finally中保证try中的50个线程执行完)
countDownLatch.await(); //等待所有countDown()执行完
02 数组类型原子类
AtomicIntegerArray
、AtomicLongArray
、AtomicReferenceArray
;自己研究很简单。
03 引用类型原子类
AtomicReference
:自旋锁那里讲了。
AtomicStampedReference
:携带版本号的引用类型原子类,可以解决ABA问题。解决修改过几次。
AtomicMarkableReference
:携带标记位(bool)的引用类型原子类,可以解决一次性问题。解决是否修改过。
AtomicMarkableReference:
//构造传入初始值和标志位
AtomicMarkableReference<Integer> amr = new AtomicMarkableReference<>(100, false);
//获取标志位
boolean marked = amr.isMarked();
//CAS修改数据
amr.compareAndSet(100,101,marked,!marked);
//获取当前值
amr.getReference();
04 对象的属性修改原子类
AtomicIntegerFieldUpdater
、AtomicLongFieldUpdater
、AtomicReferenceFieldUpdater
以一种线程安全的方式操作非线程安全对象内的某些属性,它锁的粒度会比之前的小,Netty的源码就大量用到。
因为对象的属性修改类型原子类都是抽象类,所有每次使用都要用静态方法newUpdater()
创建。
1.AtomicIntegerFieldUpdater
private volatile int money = 0;
AtomicIntegerFieldUpdater<BankAccount> fieldUpdater =
AtomicIntegerFieldUpdater.newUpdater(BankAccount.class, "money");
//这里就不会发生线程安全问题了
fieldUpdater.incrementAndGet(this); //自增
2.AtomicReferenceFieldUpdater
private volatile Boolean isInit = Boolean.FALSE;
AtomicReferenceFieldUpdater<MyVar,Boolean> fieldUpdater =
AtomicReferenceFieldUpdater.newUpdater(MyVar.class, Boolean.class, "isInit");
//可以通过CAS去控制
if (fieldUpdater.compareAndSet(this, Boolean.FALSE, Boolean.TRUE)) {
//抢到了就处理
} else {
//没抢到就直接抛弃了,就不会自旋了
}
05 原子操作增强类
LongAdder
、LongAccumulato
、DoubleAdder
、DoubleAccumulato
;
阿里开发手册:如果是JDK8,推荐使用LongAdder对象,比AtomicLong性能更号(减少乐观锁的重试次数)。
1.LongAdder常用API
void add(long x)
:当前value加x。
void increment()
:当前value加1。
void decrement()
:当前value减1。
long sum()
:返回当前值。在没有并发更新value时,会返回一个精确值,否则sum()不保证值的精确性。
void reset()
:将value重置为0,可替代重新new一个,但是此方法只能用在没有并发更新时使用。
long sumThenReset()
:获取当前value重置为0。
2.LongAccumulator
LongAdder只能用来计算加法,且从零开始计算。所以就有了LongAccumulator可以自定义函数操作计算。
//构造参数1函数式接口:(value, 被计算的值) -> 计算方法(加减乘除等),并赋值给value
LongAccumulator longAccumulator = new LongAccumulator((x, y) -> x + y, 0);//0赋值给value
longAccumulator.accumulate(1);//传入Lambda,并调用
longAccumulator.accumulate(2);
longAccumulator.accumulate(3);
System.out.println(longAccumulator.longValue());//返回value为6
06 LongAdder源码
1.架构
由图可知,LongAdder是Striped64
的子类。
1.Striped64类,分析
// CPU数量,即cells数组的最大长度(要确保每个线程对应一个cells槽)
static final int NCPU = Runtime.getRuntime().availableProcessors();
//cells数组,为2的幂,2,4,8,16.....,方便以后位运算
transient volatile Cell[] cells;
//基础value值,当并发较低时,只累加该值主要用于没有竞争的情况,通过CAS更新。
transient volatile long base;
//创建或者扩容Cells数组时使用的自旋锁变量调整单元格大小(扩容),创建单元格时使用的锁。
transient volatile int cellsBusy;
base:非竞态条件下,直接累加到该变量上。
Cell[]:竞态条件下,累加到各个线程自己槽Cell[i]中。
最终的value = base + Cell;
2.LongAdder为什么这么快?
LongAdder的基本思路就是分散热点,将value值分散到一个Cell数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。
sum()会将所有Cell数组中的value和base累加作为返回值,核心的思想就是将之前AtomicLong一个value的更新压力分散到多个value中去,从而降级更新热点。
3.add(long x)方法源码分析
public void add(long x) {
//b是Striped64中的base属性,v是当前线程hash到Cell中存储的值
//m是cells长度-1,hash时作为掩码使用
//c是当前线程hash到的Cell
Cell[] cs; long b, v; int m; Cell c;
//条件2:cas操做base失败,说明其它线程先一步修改了base正在出现竞争
if ((cs = cells) != null || !casBase(b = base, b + x)) {
//返回线程中的threadLocalRandomProbe字段(就是线程的hash值)
int index = getProbe();
boolean uncontended = true; //false表示竞争激烈,多个线程hash同一个Cell,可能要扩容
//条件1:cells为空,说明正在出现竞争,上面是从条件2过来的
//条件2:应该不会出现
//条件3:当前线程所在的Cell为空,说明当前线程还没更新过Cell,应初始化一个Cell
//条件4:更新当前线程所在的Cell失败,说明现在竞争很激烈,多个线程hash到同一个Cell,应扩容
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[index & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended, index);//调用Striped64中的方法处理
}
}
最处无竞争时只更新base;如果更新base失败后,首次新建一个Cell[]数组;当多个线程竞争同一个Cell比较激烈时,可能就要对Cell[]扩容。
4.longAccumulate源码分析(Striped64.java)
casCellsBusy():通过CAS操作修改cellsBusy的值,CAS成功代表获取锁,返回true。
getProbe():获取当前线程的hash值。
advanceProbe():重置当前线程的hash值。
//入参:x是需要增加的值,一般都是1;fn默认传递的是null;
//wasUncontended竞争标识,如果是false则代表有竞争。只有cells初始后,并且当前线程CAS竞争修改失败,才会是false
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended, int index) {
//如果线程hash值为0,就给他重新分配一个hash值
if (index == 0) {
ThreadLocalRandom.current(); // force initialization
index = getProbe();
wasUncontended = true;
}
for (boolean collide = false;;) { // 自旋
Cell[] cs; Cell c; int n; long v;
//CASE1:Cell[]数组已经初始化
if ((cs = cells) != null && (n = cs.length) > 0) {
//1.判断hash后指向的位置是否为空,如果为空将Cell数据放入,跳出循环。否则继续循环。
//当前线程的hash值运算后映射得到Cell单元为null,说明该Cell没有被使用
if ((c = cs[(n - 1) & index]) == null) {
if (cellsBusy == 0) { //Cell[]数组没有正在扩容
Cell r = new Cell(x); //创建一个Cell单元
//尝试加锁,成功后cellsBusy = 1
if (cellsBusy == 0 && casCellsBusy()) {
try { //在有锁的情况下再检测一遍之前的判断
Cell[] rs; int m, j;//将Cell单元附到Cell[]数组上
if ((rs = cells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & index] == null) {
rs[j] = r;
break;
}
} finally {
cellsBusy = 0;
}
continue; // Slot is now non-empty
}
}
collide = false;
}
//wasUncontended表示cells初始化后,当前线程竞争修改失败
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
//走到这里说明,当前线程对应的数组中有了数据,也重置过hash值
//通过CAS尝试对value累计x,x默认为1,如果成功则直接跳出循环
else if (c.cas(v = c.value,
(fn == null) ? v + x : fn.applyAsLong(v, x)))
break;
//如果n大于CPU最大数量,不可扩容
else if (n >= NCPU || cells != cs)
collide = false; // At max size or stale
//如果扩容意向是false则修改为true,然后重新计算当前线程hash值继续循环
else if (!collide)
collide = true;
else if (cellsBusy == 0 && casCellsBusy()) {
try {//当前的cells数组和最先赋值的cs是同一个,代表没有被其他线程扩容过
if (cells == cs) // Expand table unless stale
//按位左移1位来操作,扩容大小为原来的两倍
cells = Arrays.copyOf(cs, n << 1);
} finally {
cellsBusy = 0;//释放设置,设置扩容状态,然后继续循环执行
}
collide = false;
continue; // Retry with expanded table
}
index = advanceProbe(index);//修改线程的probe(线程hash值)在重新尝试
}
//CASE2:Cell[]数组未初始化(首次新建)
else if (cellsBusy == 0 && cells == cs && casCellsBusy()) {
try { // Initialize table
if (cells == cs) { //不double check,就会再次new一个cell数组
Cell[] rs = new Cell[2];
rs[index & 1] = new Cell(x);
cells = rs;
break;
}
} finally {
cellsBusy = 0;
}
}
//CASE3:Cell[]数组正在初始化中
//多个线程尝试CAS修改失败的会走到这个分支(兜底)
//该分支直接将值累加到base上
else if (casBase(v = base,
(fn == null) ? v + x : fn.applyAsLong(v, x)))
break;
}
}
5.sum()
//sum = base + Cell
public long sum() {
Cell[] cs = cells;
long sum = base;
if (cs != null) {
for (Cell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
sum执行时,并没有限制对base和cells的更新(一句要命的话)。所以LongAdder不是强一致性的,它是最终一致性的。
6.小总结
LongAdder在无竞争的情况,跟AtomicLong一样,对同一个base进行操作,当出现竞争关系时则是采用化整为零的做法,从空间换时间,用一个数组cells,将一个value拆分进这个数组cells。多个线程需要同时对value进行操作时候,可以对线程id进行hash得到hash值,再根据hash值映射到这个数组cells的某个下标,再对该下标所对应的值进行自增操作。当所有线程操作完毕,将数组cells的所有值和无竞争值base都加起来作为最终结果。
07 总结
1.AtomicLong
原理:CAS + 自旋(incrementAndGet)
场景:低并发下的全局计算。能保证并发计数的准确性,内部通过CAS来解决并发安全问题。
缺陷:高并发后性能急剧下降。
2.LongAdder
原理:CAS + Base + Cell数组分散
场景:高并发下的全局计算。
缺陷:sum求和后还有计算线程修改结果的话,最后结果不够准确。
第9章 ThreadLocal
01 简介
ThreadLocal提供线程局部变量。这些变量与正常的变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过其get或set方法)都有自己的、独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如,用户ID或事务ID)与线程关联起来。
实现每一个线程都有自己专属的本地变量副本(自己用自己的变量不麻烦别人,不和其他人共享,人人有份,人各一份),主要解决了让每个线程绑定自己的值,通过使用get()和set()方法,获取默认值或将其值更改为当前线程所存的副本的值从而避免了线程安全问题。
常用API
T get()
:查看当前value。
void set(T value)
:更新当前value。
void remove()
:删除当前线程的ThreadLocal对象,set后一定要删除,get后不需要。
static withInitial(Supplier)
:创建ThreadLocal对象,通过函数式接口传入初始值value。
02 源码分析
//Thread.java
//每个线程对象都会维护一个ThreadLocalMap
ThreadLocal.ThreadLocalMap threadLocals = null;
//Threadlocal.java
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
static class ThreadLocalMap {
//它就是真正存储数据的
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
//构造
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY]; //INITIAL_CAPACITY默认是16
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
//...一些属性和方法
}
ThreadLocalMap从字面上就可以看出这是一个保存ThreadLocal对象的map(其实是以ThreadLocal为Key),不过是经过了两层包装的ThreadLocal对象。
JVM内部维护了一个线程版的Map<Thread,T>(通过ThreadLocal对象的set方法,结果把ThreadLocal对象自己当做key,放进了ThreadLoalMap中),每个线程要用到这个T的时候,用当前的线程去Map里面获取,通过这样让每个线程都拥有了自己独立的变量,人手一份,竞争条件被彻底消除,在并发模式下是绝对安全的变量。
03 内存泄露
我们更具上述源码可知,Entry继承了WeakReference<ThreadLocal<?>>
虚引用,说明当前ThreadLocal对象被回收后,这里的key也会被回收。key最后会被置为nulll。
但是,有没有一种情况(key = null, value = 大对象),这里ThreadLocal对象已经被回收了,但value值还没有,并且还是大对象,那么只能等线程销毁才能被回收,但生产一般用的是线程池,线程一般不会销毁,那这不OOM?
所以为了解决这一问题在remove()
方法中调用了ThreadLocalMap::expungeStaleEntry()
,是专门处理这种情况的,所以这也是为什么说set()以后一定要手动remove()。
阿里开发手册上也明确指出,用完记得手动remove。
第10章 对象内存布局
01 对象堆中布局
对象在堆内存中的存储布局
对象内部结构分为:对象头、实例数据、对齐填充(保证8个字节的倍数)。
对象头分为对象标记(markOop)和类元信息(klassOop),类元信息存储的是指向该对象类元数据(klass)的首地址。
1.对象头
对象标记Mark Word:默认存储对象的HashCode、分代年龄和锁标志位等信息。
类元信息(又叫类型指针):指向元类元数据(方法区)的指针,JVM通过这个指针来确定是哪个类的实例。
对象头多大:Mark Word占8个字节,类型指针占8个字节,一共16个字节。
2.实例数据
存放类的属性数据信息,包括父类属性信息,如果是数组还包括长度信息,这部分内存按4字节对齐。
3.对齐填充
JVM要求对象起始地址必须是8字节的整数倍。对齐填充的意义仅仅是为了字节对其。
02 MarkWord
对象布局、GC回收后面的锁升级就是对象标记MarkWord里面标志位的变化。
这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化。
第11章 synchronized锁升级
01 sync性能变化
1.JDK5以前:
synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。
2.JDK6开始:
优化Synchronized,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,需要有个逐步升级的过程,别一开始就捅到重量级锁。
3.为什么每个对象都可以成为一把锁?
//markOop.hpp
ObjectMonitor* monitor() const {
assert(has_monitor(), "check");
return (ObjectMonitor*) (value() ^ monitor_value);
}
Monitor可以理解为一种同步工具,也可理解为一种同步机制,常常被描述为一个Java对象。Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
02 偏向锁
多线程访问情况,3种:
- 只有一个线程来访问
- 有2个线程A、B来交替访问
- 竞争激烈,多个线程来访问
1.主要作用:
大多数情况下:锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
当一段同步代码一直被同一个线程多次访问,那么它后续就会自动获得锁。
2.64位标记图
通过CAS方式修改Mark Word中的线程ID。
3.偏向锁的持有
在实际运行中,锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。所以只需要在锁第一次被拥有的时候,记录偏向线程ID,后续就不需要再次加锁和释放锁。
假如有竞争,锁已经不是总偏向同一个线程了,这时就会升级成轻量级锁,才能保证公平。偏向锁只有遇到其它线程竞争偏向锁时,偏向线程才会释放锁,否则是不会主动释放的。
4.偏向锁的撤销
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会被撤销。
竞争线程尝试CAS更新对象头失败,会等到全局安全点(该时间点上没有字节码正在执行),同时检测偏向线程是否还在执行:
- 在执行sync方法,该偏向锁会被取消并出现锁升级。此时轻量级锁由原偏向线程持有,继续执行同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
- 已经执行完sync方法,则将对象头设置成无锁状态并撤销偏向锁,重新偏向。
03 轻量级锁
1.主要作用
有线程来参与锁的竞争,但是获取锁的冲突时间极短,本质就是自旋锁。
在没有多线程竞争的前提下,通过CAS减少重量级锁使用OS互斥量产生的性能消耗,说白了先自旋再阻塞。
2.自旋达到一定次数和程度
jdk6之前,默认是自旋10次或者自旋线程数超过cpu核数一半,就会升级为重量级锁。
jdk6之后,自适应,自旋次数不固定,而是根据同一个锁上一次自旋的时间和拥有锁线程的状态来决定升级。
04 总结
synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。
实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式
synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。
JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。
05 锁消除和锁粗化
1.锁消除
static Object objectLock = new Object();//正常的
//多线程调用m1
public void m1() {
//锁消除,JIT会无视它,synchronized(o)不存在了。没有加这个锁对象的底层机器码,消除了锁的使用。
Object o = new Object();
synchronized (o)
{
//...
}
}
2.锁粗化
static Object objectLock = new Object();
//JIT优化前。方法中首位相接,前后相邻都是同一把锁,那么JIT就会把这几个synchronized合并成一个大块。
new Thread(() -> {
synchronized (objectLock) {
System.out.println("11111");
}
synchronized (objectLock) {
System.out.println("22222");
}
synchronized (objectLock) {
System.out.println("33333");
}
},"a").start();
//jit优化后。加粗加大范围,一次申请锁即可,避免次次申请和释放锁,提升了性能。
new Thread(() -> {
synchronized (objectLock) {
System.out.println("11111");
System.out.println("22222");
System.out.println("33333");
}
},"a").start();
第12章 AQS
01 理论
1.是什么
AQS(AbstractQueuedSynchronizer,抽象队列同步器),是用来构建锁或其它同步器组件的重量级基础框架及整个JUC体系的基石。(ReentrantLock、CountDownLatch、ReetranReadWriteLock、Semaphore)
通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量表示持有锁的状态。
AQS就是统一规范简化锁的实现,屏蔽了同步状态管理、阻塞线程排队和通知、唤醒机制等。
2.能干嘛
CLH:Craig、Landin and Hagersten 队列,是一个单向链表,AQS中的队列是CLH变体的虚拟双向队列FIFO。
有阻塞就需要排队,实现排队必然需要队列。
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。将占时获取不到锁的线程加入队列,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。
02 内部结构
//AbstractQueuedSynchronizer.java
//Node结点实现锁的分配
abstract static class Node {
//共享(线程以共享的模式等待锁)
static final Node SHARED = new Node();
//独占(线程以独占的方式等待锁)
static final Node EXCLUSIVE = null;
//表示线程获取锁的请求已取消
static final int CANCELLED = 1;
//表示线程已经准备好了,就等资源释放了
static final int SIGNAL = -1;
//表示结点在等待队列中,线程在等待唤醒
static final int CONDITION = -2;
//共享式同步状态获取将会无条件地传播下去(当前线程为SHARED,该字段才会使用)
static final int PROPAGATE = -3;
//初始为0,状态是上面的几种
volatile int waitStatus;
//处于该结点的线程
volatile Thread thread;
}
//通过它来表示同步状态(通过CAS完成对它的修改)
private volatile int state;
state:0就是没人。大于等于1就是有人占用,通过自旋等待。
CLH队列:存储等待的线程。
AQS = state变量 + CLH双端队列
第13章 读写锁邮戳锁
无锁
——> 独占锁
——> 读写锁
——> 邮戳锁
;
独占锁
就是ReentrantLock、synchronized,它们可以保证有序数据一致,但每次只能来一个。
读读一个(×)、写写一个(√)、读写一个(√),全部互斥。
01 读写锁
1.ReentrantReadWriteLock读写锁
读写互斥、读读共享,提升大面积的共享性能同时多个人来读。
所以它并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的。
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁(切菜还是拍蒜选一个)。
也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。
2.读写锁缺点
-
锁饥饿问题,写锁线程获取不到锁
-
读的过程中,如果没有释放,写线程不可以获取锁。必须读完后,才有机会写。
3.常用API
对象.readLock().lock()
:加读锁
对象.readLock().unlock()
:释放读锁
对象.writeLock().lock()
:加写锁
对象.writeLock().unlock()
:释放写锁
跟ReentrantLock用法差不多。
4.读写锁降级
锁的严苛程度变强叫做升级,反之叫做降级;ReentrantReadWriteLock也支持公平性和重入性;还支持锁降级;
锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。
目的是为了让当前线程感知的数据的变化,保证数据可见性。
如果有线程在读,那么写线程是无法获取写锁的,必须等待,这是一种悲观的读锁。(没有锁升级)
02 邮戳锁
StampedLock邮戳锁(也叫票据锁),它比读写锁更快。
是JDK1.8中新增的,是对JDK1.5中的ReentrantReadWriteLock的优化。
通过stamp(戳记,long类型)代表锁的状态,当返回0时,表示线程获取锁失败。并且当释放锁或转换锁的时候,都要传入最处获取的stamp值。
1.它是由锁饥饿问题引出
ReentrantReadWriteLock实现了读写分离,但一旦读操作比较多的时候,获取写锁就变得非常困难了,可能会导致当前会一直存在读锁,而无法获取写锁,根本没机会写。(锁饥饿问题)
当然可以同公平锁来解决锁饥饿,但公平锁是以牺牲系统吞吐量为代价的。
所以就引出了StampedLock邮戳锁,采用乐观获取锁后,其它线程尝试获取写锁不会被阻塞
,这是对读锁的优化,所以在获取乐观读锁后,还需要对结果进行校验。
2.StampedLock的特点
所有获取锁的方法,都会返回一个stamp,0表示获取失败,其余都表示成功。
所有释放锁的方法,有需要一个stamp,这个stamp必须是和成功获取锁时得到的一致。
StampedLock是不可重入的,危险,可能导致死锁。
StampedLock有三种访问模式:
- Reading(读模式):功能跟读写锁类似。
- Writing(写模式):功能跟读写锁类似。
- Optimistic reading(乐观读模式):无锁机制,支持读写并发。
- 很乐观的认为读取时没有人修改,如果有就会升级为悲观读模式。
3.StampedLock缺点
-
不支持重入,没有Re开头。
-
悲观读锁和写锁都不支持条件变量(Condition)。
-
使用时一定不能调用中断操作。
所以一般也就面试用用,实际工作中不要用。
—> 邮戳锁
;
独占锁
就是ReentrantLock、synchronized,它们可以保证有序数据一致,但每次只能来一个。
读读一个(×)、写写一个(√)、读写一个(√),全部互斥。
01 读写锁
1.ReentrantReadWriteLock读写锁
读写互斥、读读共享,提升大面积的共享性能同时多个人来读。
所以它并不是真正意义上的读写分离,它只允许读读共存,而读写和写写依然是互斥的。
一个ReentrantReadWriteLock同时只能存在一个写锁但是可以存在多个读锁,但不能同时存在写锁和读锁(切菜还是拍蒜选一个)。
也即一个资源可以被多个读操作访问或一个写操作访问,但两者不能同时进行。
只有在读多写少情境之下,读写锁才具有较高的性能体现。
2.读写锁缺点
-
锁饥饿问题,写锁线程获取不到锁
-
读的过程中,如果没有释放,写线程不可以获取锁。必须读完后,才有机会写。
3.常用API
对象.readLock().lock()
:加读锁
对象.readLock().unlock()
:释放读锁
对象.writeLock().lock()
:加写锁
对象.writeLock().unlock()
:释放写锁
跟ReentrantLock用法差不多。
4.读写锁降级
锁的严苛程度变强叫做升级,反之叫做降级;ReentrantReadWriteLock也支持公平性和重入性;还支持锁降级;
锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。
目的是为了让当前线程感知的数据的变化,保证数据可见性。
如果有线程在读,那么写线程是无法获取写锁的,必须等待,这是一种悲观的读锁。(没有锁升级)
02 邮戳锁
StampedLock邮戳锁(也叫票据锁),它比读写锁更快。
是JDK1.8中新增的,是对JDK1.5中的ReentrantReadWriteLock的优化。
通过stamp(戳记,long类型)代表锁的状态,当返回0时,表示线程获取锁失败。并且当释放锁或转换锁的时候,都要传入最处获取的stamp值。
1.它是由锁饥饿问题引出
ReentrantReadWriteLock实现了读写分离,但一旦读操作比较多的时候,获取写锁就变得非常困难了,可能会导致当前会一直存在读锁,而无法获取写锁,根本没机会写。(锁饥饿问题)
当然可以同公平锁来解决锁饥饿,但公平锁是以牺牲系统吞吐量为代价的。
所以就引出了StampedLock邮戳锁,采用乐观获取锁后,其它线程尝试获取写锁不会被阻塞
,这是对读锁的优化,所以在获取乐观读锁后,还需要对结果进行校验。
2.StampedLock的特点
所有获取锁的方法,都会返回一个stamp,0表示获取失败,其余都表示成功。
所有释放锁的方法,有需要一个stamp,这个stamp必须是和成功获取锁时得到的一致。
StampedLock是不可重入的,危险,可能导致死锁。
StampedLock有三种访问模式:
- Reading(读模式):功能跟读写锁类似。
- Writing(写模式):功能跟读写锁类似。
- Optimistic reading(乐观读模式):无锁机制,支持读写并发。
- 很乐观的认为读取时没有人修改,如果有就会升级为悲观读模式。
3.StampedLock缺点
-
不支持重入,没有Re开头。
-
悲观读锁和写锁都不支持条件变量(Condition)。
-
使用时一定不能调用中断操作。
所以一般也就面试用用,实际工作中不要用。