ㅤ
ㅤ
进程
线程
二者对比
进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
进程拥有共享的资源,如内存空间等,供其内部的线程共享
进程间通信较为复杂
同一台计算机的进程通信称为 IPC(Inter-process communication)
不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
ㅤ
并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
ㅤ
ㅤ
ㅤ
方法一,直接使用 Thread
方法二,使用 Runnable 配合 Thread
方法三,callable(比如配合FutureTask使用,作为FutureTask的一个成员变量,当任务执行时调用callable的call方法,并将返回结果赋给FutureTask的一个成员变量)
ㅤ
关系,Thread的本质是调用runnable的run方法
callable使用案例:
// 创建任务对象
FutureTask<Integer> task3 = new FutureTask<>(() -> {
log.debug("hello");
return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);
ㅤ
ㅤ
windows
linux
Java
支持流水线的处理器(指令重排的原因)
现代 CPU 支持多级指令流水线,例如支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
指令重排前,一个一个指令执行
ㅤ
指令重排后
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行(提高了吞吐量,也就提高了程序的运行速度)
ㅤ
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
Context Switch 频繁发生会影响性能
ㅤ
了解:上下文切换会带来直接和间接两种因素影响程序性能的消耗. 直接消耗包括: CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉; 间接消耗指的是多核的cache之间得共享数据, 间接消耗对于程序的影响要看线程工作区操作数据的大小).
ㅤ
直接调用 run 是在主线程中执行了 run,没有启动新的线程
使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
ㅤ
sleep
调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出
InterruptedException
睡眠结束后的线程未必会立刻得到执行
建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
ㅤ
yield
调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
具体的实现依赖于操作系统的任务调度器
ㅤ
ㅤ
需要等待结果返回,才能继续运行(可以实现同步)
ㅤ
打断 sleep,wait,join(join的底层还是调用wait) 的线程(打断后都会抛InterruptedException,并且都会清除标记状态)
打断sleep会清空打断状态
打断正常运行的线程, 不会清空打断状态
ㅤ
先利用interrupt()来打断sleep或者wait的线程,此时线程并没有直接结束,还有一个料理后事的处理,当料理完后事后线程再结束
while(true) {
Thread current = Thread.currentThread();
if(current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
//继续打断,因为打断状态会被清除
current.interrupt();
}
// 执行监控操作
}
1. 定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,比如单例模式
ㅤ
public class MonitorService {
// 用来表示是否已经有线程已经在执行启动了
private volatile boolean starting;
public void start() {
log.info("尝试启动监控线程...");
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
// 真正启动监控线程...
}
}
ㅤ
打断 park 线程, 不会清空打断状态
如果打断标记已经是 true, 则 park 会失效
private static void test4() {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
log.debug("park...");
LockSupport.park();
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}
});
t1.start();
sleep(1);
t1.interrupt();
}
21:13:48.783 [Thread-0] c.TestInterrupt - park...
21:13:49.809 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.812 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
21:13:49.813 [Thread-0] c.TestInterrupt - park...
21:13:49.813 [Thread-0] c.TestInterrupt - 打断状态:true
ㅤ
stop会直接终止线程,直接释放所有占用的资源,会破坏锁结构
suspend和resume对应,前者挂起线程,后者恢复线程(使用会很容易造成死锁)
例子:
Object o=new Object();
Thread t=new Thread(() ->{
synchronized (o){
log.debug("suspend前");
Thread.currentThread().suspend();//挂起
log.debug("suspend后");
}
});
t.start();
Thread.sleep(1000);
synchronized (o){
t.resume();//该行代码永远也不会被执行,导致死锁
}
log.debug("main end");
ㅤ
初始状态
可运行状态(就绪状态)
运行状态
阻塞状态
终止状态
ㅤ
NEW 线程刚被创建,但是还没有调用 start() 方法
RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的
【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
TERMINATED 当线程代码运行结束
ㅤ
join,wait/notify,synchronized实现线程之间的通信
ㅤ
多线程时指令交错运行带来的数据一致性问题
比如:i=0,一个线程执行i++,一个线程执行i–,最终结果不一定是0
一个程序运行多个线程本身是没有问题的
问题出在多个线程访问共享资源
多个线程读共享资源其实也没有问题
在多个线程对共享资源读写操作时发生指令交错,就会出现问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
ㅤ
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
ㅤ
悲观互斥(synchronized,lock),乐观互斥(CAS,原子变量就是使用CAS)
注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
ㅤ
ㅤ
成员变量和静态变量是否线程安全?
如果它们没有共享,则线程安全
如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
如果只有读操作,则线程安全
如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
局部变量是线程安全的
但局部变量引用的对象则未必
如果该对象没有逃离方法的作用访问,它是线程安全的
如果该对象逃离方法的作用范围,需要考虑线程安全
ㅤ
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。它们的每个方法是原子的,但注意它们多个方法的组合不是原子的
例如下面是线程不安全的
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
ㅤ
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。String 有 replace,substring 等方法是通过生成一个新对象来保证线程的安全。
ㅤ
java对象头
其中klass word为指向类的指针
其中 Mark Word 结构为
ㅤ
Monitor 原理
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
Monitor 结构如下
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
ㅤ
两个指令
monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
可以自己查看字节码
注意
方法级别的 synchronized 不会在字节码指令中有所体现
ㅤ
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized
ㅤ
轻量级锁加锁过程:
几个概念:锁记录对象、锁记录(指向锁记录对象,加锁后存储mark word信息,和mark word一样占用32个字节)、对象指针,锁记录对象=锁记录+对象指针
进入同步代码块时(当偏向锁升级为轻量级锁),此时不会直接关联一个monitor锁对象,而是先看对象是否被其它线程线程加锁,如果对象没有被加锁。则在当前栈帧中创建一个锁记录Lock record对象(内部包含了一个锁记录地址和对象指针),先将mark word的值存入锁记录中,然后使用CAS的方式将对象的mark word修改为锁记录的指针(交换信息),并且改变锁记录对象的对象指针,指向被加锁的对象。如果cas替换成功,此时对象的对象头存储了锁记录地址和状态00,表示由该线程给对象加锁。如果cas失败有两种情况
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
成功,则解锁成功
失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
ㅤ
锁膨胀过程
锁被线程0占有,那么线程1进行轻量级加锁cas会失败(且自旋也没有获得锁),进入锁膨胀流程
先为对象申请 Monitor 锁,让 对象的mark word重新指向重量级锁地址
然后线程1进入 Monitor 的 EntryList (BLOCKED)
当线程0释放锁时,使用 cas 将 Mark Word 的值恢复给对象头,会失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象(应该是想将mark word信息保存到monitor中),设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程。
ㅤ
其实加锁的本质就是改变对象头的mark word,让其它线程知道该对象被其它线程加锁,无论是偏向锁,轻量级锁,重量级锁,都是改变mark word
内核态和用户态
内核态与用户态是操作系统的两种运行级别。我们的应用程序一般是处在用户态,但当我们需要硬盘数据读取等相关操作时,这些操作是不能直接在用户态进行的。需要转换到内核态帮我们进行处理。内核态和用户态之间发生切换对性能是有一定影响的。
https://www.cnblogs.com/wely/p/6198681.html
用户态和内核态切换耗时的原因
https://www.cnblogs.com/gtblog/p/12155109.html
ㅤ
ㅤ
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。
自旋锁的目的就是为了减少线程用户态和内核态之间的切换。自旋获得的锁任然是重量级锁,只是自旋不会让线程变成阻塞状态(阻塞就会涉及到上面提到的状态切换)。但自旋锁会占用cpu时间。且在多核下才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java 7 之后不能控制是否开启自旋功能。
ㅤ
轻量级锁在没有竞争时(就自己这个线程),每次加锁都需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
偏向状态
一个对象创建时:
对于偏向锁,在线程获取偏向锁时,会用 Thread ID 和 epoch 值覆盖 identity hash code 所在的位置。如果一个对象的hashCode() 方法已经被调用过一次之后,这个对象还能被设置偏向锁么?答案是不能。因为如果可以的化,那 Mark Word中 的 identity hash code 必然会被偏向线程Id给覆盖,这就会造成同一个对象前后两次调用 hashCode() 方法得到的结果不一致。参考https://blog.csdn.net/weixin_43935927/article/details/114844703
注意
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
ㅤ
撤销 - 调用对象 hashCode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
ㅤ
撤销 - 其它线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
ㅤ
撤销 - 调用 wait/notify(本质还是其它线程使用了对象)
ㅤ
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
ㅤ
偏向锁冲突
自己的理解:当线程1持有偏向锁时,线程2也来尝试获取锁,此时线程2获取偏向锁肯定会失败,线程2根据对象mark word中的线程id找到线程1,如果线程1死亡,那么将对象头设置为无锁状态。如果线程1仍然活着,那么线程1会将对象的markword设置为null,然后尝试获取对象的轻量级锁。线程2也会尝试获取轻量级锁,失败则自旋,自旋失败则升级为重量级锁。
ㅤ
偏向锁总结
已经升级为轻量级锁的对象无法再使用偏向锁
偏向锁偏向是在类级别上的,偏向次数达到一定量(20),那么其它线程去获取锁时就会偏向该线程,如果偏向次数达到40次,那么该类的所有对象无法再偏向其它线程(出现锁竞争直接升级为轻量级锁)。
ㅤ
锁消除
加锁对象只被一个线程访问
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
}
ㅤ
锁粗化
对相同对象多次加锁,导致线程发生多次重入,可以使用锁粗化方式来优化,这不同于之前讲的细分锁的粒度。
ㅤ
1、检查Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态
2、如果为可偏向状态,则检查线程ID是否指向当前线程,如果是则表示当前线程处于偏向锁状态,然后执行同步代码
3、如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程,偏向标志位设置为1,锁标志位设置为01,然后执行同步码块
4、如果竞争失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁
5、当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
6、如果替换失败,表示其他线程竞争锁,当前线程尝试自旋获取锁(前提是对象已经关联了一个monitor,否则不会自旋,直接升级为重量级锁。因为自旋锁是解决重量级锁竞争,预防线程进入entrylist,因为会存在状态切换)
7、如果自旋成功,则将monitor中的owner设置为当前线程
8、如果自旋失败,则进入entrylist,进入重量级锁流程
ㅤ
关于整个Synchronized原理非常值得一看。里面也讲到了自旋锁只有在重量级锁竞争是才会出现
https://www.cnblogs.com/yescode/p/14474104.html
ㅤ
参考https://blog.csdn.net/weixin_39882948/article/details/114599536
ㅤ
ㅤ
sleep 是 Thread 方法,而 wait 是 Object 的方法
sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
它们状态 TIMED_WAITING
ㅤ
一个线程需要等待另外一个线程的结果,因此这个线程需要先暂停,等待另外一个线程的通知后再运行(wait,notify)
class GuardedObject {
private Object response;
private final Object lock = new Object();
public Object get() {
synchronized (lock) {
// 条件不满足则等待
while (response == null) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}
public void complete(Object response) {
synchronized (lock) {
// 条件满足,通知等待线程
this.response = response;
lock.notifyAll();
}
}
}
ㅤ
1. 定义
要点
当消费者线程无法获取到消息队列的消息时(及消息队列为空),消费者线程就会被阻塞
当消息队列满时,生产者也无法添加消息到消息队列中,生产者线程将会一直被阻塞
当阻塞的生产者线程或者消费者线程达到一定量时,消息队列会有相应的处理策略
ㅤ
是调用者轮询检查线程 alive 状态
t1.join();
等价于下面的代码
synchronized (t1) {
// 调用者线程进入 t1 的 waitSet 等待, 直到 t1 运行结束
while (t1.isAlive()) {
t1.wait(0);
}
}
注意
join 体现的是【保护性暂停】模式,请参考之
ㅤ
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
特点
与 Object 的 wait & notify 相比
ㅤ
park unpark 原理
首先Paker对象的三个成员counter(计数器),mutex(互斥量),cond(条件变量,类似monitor中的waitset)
线程会关联一个Paker对象,当调用park时,会先检查计数器counter是否为1,是1则将counter设置为0继续运行,如果是0,则线程进入条件变量中阻塞。
当调用unpark时,如果cond条件变量不为空,则唤醒cond条件变量中的线程,线程恢复运行,counter设置为0,如果cond条件变量为空,则将counter设置为1,当下次调用park后可以继续运行,不会被阻塞
总之三种情况:counter为1并且cond为空,counter为0并且cond为null,counter为0并且cond不为null
关于park unpark的bug,总之先park再unpark不会出错
ㅤ
ㅤ
new
runnable
terminated
其中runnable包括
阻塞状态包括
就绪状态和运行状态为RUNNABLE
ㅤ
class BigRoom {
private final Object studyRoom = new Object();
private final Object bedRoom = new Object();
public void sleep() {
synchronized (bedRoom) {
log.debug("sleeping 2 小时");
Sleeper.sleep(2);
}
}
public void study() {
synchronized (studyRoom) {
log.debug("study 1 小时");
Sleeper.sleep(1);
}
}
}
将锁的粒度细分
ㅤ
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁
t1 线程 获得 A对象 锁,接下来想获取 B对象 的锁 t2 线程 获得 B对象 锁,接下来想获取 A对象 的锁。这样就会造成死锁。
定位死锁
检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁
ㅤ
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
new Thread(() -> {
// 期望减到 0 退出循环
while (count > 0) {
sleep(0.2);
count--;
log.debug("count: {}", count);
}
}, "t1").start();
new Thread(() -> {
// 期望超过 20 退出循环
while (count < 20) {
sleep(0.2);
count++;
log.debug("count: {}", count);
}
}, "t2").start();
ㅤ
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不
易演示,讲读写锁时会涉及饥饿问题
ㅤ
相对于 synchronized 它具备如下特点
基本语法
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
ㅤ
通过使用wait/notify,reentrantlock的condition,park/unpark来实现线程之间同步控制。
ㅤ
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
ㅤ
退不出的循环
一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
ㅤ
初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率
1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
解决方法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
ㅤ
可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况。只能保证看到最新值,不能解决指令交错
注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?(里面使用了synchronized)
这里额外加一句:synchronized在加锁时和解锁时的对象会从主存中读取,但在synchronized代码块内部是不保证可见性的。例子如下,程序执行后仍然会卡在循环中
public class Test {
static class N{
int state=0;
}
public static void main(String[] args) throws InterruptedException {
N n=new N();
new Thread(()-> {
synchronized (n){//加锁时读屏障去主存中读取对象
System.out.println("thread start");
while (n.state==0){
//中间代码块不保证可见性
}
System.out.println("thread end");
}//释放锁时写屏障强制写入主存
}).start();
Thread.sleep(100);
n.state=1;
System.out.println("main end");
}
}
ㅤㅤ
ㅤ
ㅤ
查看 cpu 缓存 lscpu
查看cpu 缓存行cat/sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
大小是64
ㅤ
cpu 拿到的内存地址格式是这样的
[高位组标记] [低位索引] [偏移量]
ㅤ
读取数据流程如下
根据低位,计算在缓存中的索引
判断是否有效
0 去内存读取新数据更新缓存行
1 再对比高位组标记是否一致
ㅤ
MESI中每个缓存行都有四个状态,分别是E(exclusive)、M(modified)、S(shared)、I(invalid)。下面我们介绍一下这四个状态分别代表什么意思。
M:代表该缓存行中的内容被修改了,并且该缓存行只被缓存在该CPU中。这个状态的缓存行中的数据和内存中的不一样,在未来的某个时刻它会被写入到内存中(当其他CPU要读取该缓存行的内容时。或者其他CPU要修改该缓存对应的内存中的内容时(个人理解CPU要修改该内存时先要读取到缓存中再进行修改),这样的话和读取缓存中的内容其实是一个道理)。
E:E代表该缓存行对应内存中的内容只被该CPU缓存,其他CPU没有缓存该缓存对应内存行中的内容。这个状态的缓存行中的内容和内存中的内容一致。该缓存可以在任何其他CPU读取该缓存对应内存中的内容时变成S状态。或者本地处理器写该缓存就会变成M状态。
S:该状态意味着数据不止存在本地CPU缓存中,还存在别的CPU的缓存中。这个状态的数据和内存中的数据是一致的。当有一个CPU修改该缓存行对应的内存的内容时会使该缓存行变成 I 状态。
I:代表该缓存行中的内容时无效的。
ㅤ
MESI 协议
E、S、M 状态的缓存行都可以满足 CPU 的读请求
E 状态的缓存行,有写请求,会将状态改为 M,这时并不触发向主存的写
E 状态的缓存行,必须监听该缓存行的读操作,如果有,要变为 S 状态
M 状态的缓存行,必须监听该缓存行的读操作,如果有,先将其它缓存(S 状态)中该缓存行变成 I 状态(即6.的流程),写入主存,自己变为 S 状态
S 状态的缓存行,有写请求,走 4. 的流程
S 状态的缓存行,必须监听该缓存行的失效操作,如果有,自己变为 I 状态
I 状态的缓存行,有读请求,必须从主存读取
ㅤ
MESI协议 ,是一个缓存一致性协议。MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
JMM,包括其中的volatile,synchronized等关键字,解决的是内存一致性问题。
注意,一个是缓存一致性,一个是内存一致性。
缓存一致性(Cache Coherence),硬件层面的问题,指的是由于多核计算机中有多套缓存,各个缓存之间的数据不一致性问题。缓存一致性协议,如MESI解决是多个缓存副本之间的数据的一致性问题。
内存一致性(Memory Consistency),保证的是多线程程序访问内存时可以读到什么值。就是保证并发场景下的程序运行结果和程序员预期是一样的(当然,要通过加锁等方式),包括的就是并发编程中的原子性、有序性和可见性。
Java多线程中,每个线程都有自己的工作内存,需要和主存进行交互。这里的工作内存和计算机硬件的缓存并不是一回事儿,只是可以相互类比。所以,并发编程的可见性问题,是因为各个线程之间的本地内存数据不一致导致的,和计算机缓存并无关系(~也是有一点关系的,缓存其实是算作工作内存的一部分,jit优化时可能将数据放入缓存中)。
缓存一致性协议。用来解决缓存一致性问题的,常用的是MESI协议。
内存一致性模型。屏蔽计算机硬件问题,主要来解决并发编程中的原子性、有序性和一致性问题。
实现内存一致性模型的时候可能会用到缓存一致性模型。
参考:https://www.zhihu.com/question/268021813/answer/576022615
ㅤ
1. 定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,比如单例模式
public class MonitorService {
// 用来表示是否已经有线程已经在执行启动了
private volatile boolean starting;
public void start() {
log.info("尝试启动监控线程...");
synchronized (this) {
if (starting) {
return;
}
starting = true;
}
// 真正启动监控线程...
}
}
ㅤ
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
如何保证可见性
如何保证有序性
还是那句话,不能解决指令交错:
更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
下面的表是volatile有关的禁止指令重排的行为:
JVM中提供了四类内存屏障指令:
内存屏障参考:https://blog.csdn.net/huyongl1989/article/details/90712393
ㅤ
ㅤ
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
ㅤ
happens-before完整规则:
(1)同一个线程中的每个Action都happens-before于出现在其后的任何一个Action。
(2)对一个监视器(synchronized)的解锁happens-before于每一个后续对同一个监视器的加锁。
(3)对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。
(4)Thread.start()的调用会happens-before于启动线程里面的动作。
(5)Thread中的所有动作都happens-before于其他线程检查到此线程结束或者Thread.join()中返回或者Thread.isAlive()==false。
(6)线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或者t2.isInterrupted)
(7)一个对象构造函数的结束happens-before与该对象的finalizer的开始
(8)如果A动作happens-before于B动作,而B动作happens-before与C动作,那么A动作happens-before于C动作。–传递性
ㅤ
Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。
ㅤ
在循环中jit编译器可能会优化代码,读取数据时会直接从工作内存中读取,导致主存中的数据发生变化也不能及时更新。但一般没有在循环中,jit编译器往往不会进行优化,所以基本上读取数据还是从主存中读取。但即使jit编译器进行了优化,但在循环过程中遇到synchronized这样的代码块(即使该代码块没有任何内容),会使读操作从主存中获取数据。一般出现可见性问题是线程1在一个循环中,线程2修改了值,由于优化的原因,线程1中并不知道值发生了改变。总之因为编译器的优化导致了不一致问题。
ㅤ
CPU空闲后会遵循JVM优化基准,尽可能快的保证数据的可见性,从而从主存同步is变量到工作内存,最终导致程序结束,这也是为什么sleep()
方法虽然没有涉及同步操作,但是依然可以使程序终止,因为sleep()
方法会释放CPU,但不释放锁!synchronized和join应该也是类似的原因,cpu没有占用的时间比较短。
参考https://www.cnblogs.com/tv151579/p/9395452.html
ㅤ
在多线程情况下,如果只有一个线程写,其它线程读,那么可以考虑用volatile,如果有多个线程写,那么就得加锁了(synchronized,lock)
ㅤ
dcl的单例为何要加volatile(防止指令重排,防止先将创建的对象赋给引用后初始化)
ㅤ
ㅤ
注意
其实 CAS 的底层是 lock cmpxchg 指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性。
- 在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。
注意
volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
ㅤ
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
ㅤ
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
无锁竞争时效率:
无锁>偏向锁>原子整型>synchronized 1:5:15:30
ㅤ
ㅤ
ㅤ
操作原子数组的每个位置都是原子操作
ㅤ
利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常。
字段更新器是在原有的对象的成员变量上进行加强,使操作成员变量是原子操作。
而原子整形原子数组是直接作为成员变量或者局部变量。
ㅤ
LongAdder
性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]… 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。
伪共享的非标准定义为:缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
ㅤ
Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得
ㅤ
ㅤ
CPU的缓存是以缓存行(cache line)为单位的,MESI协议描述了多核处理器中一个缓存行的状态。在MESI协议中,每个缓存行有4个状态,分别是:
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。
缓存行失效的理解:当有多核时,核1和核2都缓存了同一份数据,那么核1修改,核2的缓存行就会失效。假如核1和核2各缓存了同一份数据,这份数据有两部分组成,当核1修改了其中一部分数据,核1本身的缓存行是不会失效的,失效的是核2,但第二部分数据没有被修改却也失效了。所以要将这两部分分开缓存,各占用一个缓存行。@sun.misc.Contended 用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效。
ㅤ
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改啊!这样的对象在Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类:
ㅤ
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
log.debug("{}", date);
}).start();
}
ㅤ
发现该类、类中所有属性都是 final 的
ㅤ
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的
ㅤ
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
ㅤ
总之是为了防止指令重排,添加了读写屏障
对于final域,编译器和处理器要遵守两个重排序规则:
1.在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。(先写入final变量,后调用该对象引用)
原因:编译器会在final域的写之后,插入一个StoreStore(写)屏障
2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
(先读对象的引用,后读final变量)
编译器会在读final域操作的前面插入一个LoadLoad(读)屏障
ㅤ
ㅤ
Timer(不推荐使用)
在『任务调度线程池』功能加入之前,可以使用 java.util.Timer 来实现定时功能。Timer 的优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。
public static void main(String[] args) {
Timer timer = new Timer();
TimerTask task1 = new TimerTask() {
@Override
public void run() {
log.debug("task 1");
sleep(2);
}
};
TimerTask task2 = new TimerTask() {
@Override
public void run() {
log.debug("task 2");
}
};
// 使用 timer 添加两个任务,希望它们都在 1s 后执行
// 但由于 timer 内只有一个线程来顺序执行队列中的任务,因此『任务1』的延时,影响了『任务2』的执行
timer.schedule(task1, 1000);
timer.schedule(task2, 1000);
}
输出
20:46:09.444 c.TestTimer [main] - start...
20:46:10.447 c.TestTimer [Timer-0] - task 1
20:46:12.448 c.TestTimer [Timer-0] - task 2
ScheduledExecutorService(推荐)
schedule
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
// 添加两个任务,希 它们都在 1s 后执行
executor.schedule(() -> {
System.out.println("任务1,执行时间:" + new Date());
try { Thread.sleep(2000); } catch (InterruptedException e) { }
}, 1000, TimeUnit.MILLISECONDS);
executor.schedule(() -> {
System.out.println("任务2,执行时间:" + new Date());
}, 1000, TimeUnit.MILLISECONDS);
scheduleAtFixedRate
以固定的时间间隔循环执行任务(如果任务时间太长会有影响)
scheduleWithFixedDelay(在任务执行完后延迟)
以固定的延迟循环执行任务
ㅤ
方法1:主动捉异常
ExecutorService pool = Executors.newFixedThreadPool(1);
pool.submit(() -> {
try {
log.debug("task1");
int i = 1 / 0;
} catch (Exception e) {
log.error("error:", e);
}
});
方法2:使用 Future
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<Boolean> f = pool.submit(() -> {
log.debug("task1");
int i = 1 / 0;
return true;
});
log.debug("result:{}", f.get());
ㅤ
scheduleAtFixedRate
Fork/Join 是 JDK 1.7 加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型运算
Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
ㅤ
全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架
ㅤ
读锁共享,写锁独占。读锁无法升级为写锁,但写锁可以降级为读锁,降级后写锁仍然需要手动释放。同样是基于AQS,写锁基本于ReentrantLock一致。读锁是一种共享锁,在获取到读锁后需要锁后需要唤醒后继的读锁。源码可参考https://blog.csdn.net/fxkcsdn/article/details/82217760
ㅤ
可以基于乐观读(不加锁,使用一个版本号),乐观读失败才加读锁。
StampedLock 不支持条件变量
StampedLock 不支持可重入
ㅤ
信号量,用来限制能同时访问共享资源的线程上限。
使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可。
ㅤ
用来进行线程同步协作,等待所有线程完成倒计时。
其中构造参数用来初始化等待计数值,await() 用来等待计数归零,countDown() 用来让计数减一
循环栅栏,用来进行线程协作,等待线程满足某个计数。构造时设置『计数个数』,每个线程执行到某个需要“同步”的时刻调用 await() 方法进行等待,当等待的线程数满足『计数个数』时,继续执行
注意 CyclicBarrier 与 CountDownLatch 的主要区别在于 CyclicBarrier 是可以重用的 CyclicBarrier 可以被比
喻为『人满发车』
ㅤ
java.util.concurrent.* 下的线程安全集合类,可以发现它们有规律,里面包含三类关键词:
Blocking、CopyOnWrite、Concurrent
Blocking 大部分实现基于锁,并提供用来阻塞的方法
CopyOnWrite 之类容器修改开销相对较重
Concurrent 类型的容器
内部很多操作使用 cas 优化,一般可以提供较高吞吐量
弱一致性
遍历时弱一致性,例如,当利用迭代器遍历时,如果 容器发生修改,迭代器仍然可以继续进行遍历,这时 内容是旧的
求大小弱一致性,size 操作未必是 100% 准确
读取弱一致性
遍历时如果发生了修改,对于非安全容器来讲,使用 fail-fast 机制也就是让遍历立刻失败,抛出ConcurrentModifificationException,不再继续遍历
ㅤ
使用了分段锁
jdk1.7维护了一个 segment 数组,每个 segment 对应一把锁
jdk1.8使用了synchronized+cas
jdk死链问题
Java 8 数组(Node) +( 链表 Node | 红黑树 TreeNode ) 以下数组简称(table),链表简称(bin)
ㅤ
高明之处在于用了两把锁(ReentrantLock)和 一个dummy (哨兵)节点
线程安全分析
ㅤ
主要列举 LinkedBlockingQueue 与 ArrayBlockingQueue 的性能比较
ㅤ
ConcurrentLinkedQueue 的设计与 LinkedBlockingQueue 非常像,也是
事实上,ConcurrentLinkedQueue 应用还是非常广泛的
例如之前讲的 Tomcat 的 Connector 结构时,Acceptor 作为生产者向 Poller 消费者传递事件信息时,正是采用了ConcurrentLinkedQueue 将 SocketChannel 给 Poller 使用
ㅤ
CopyOnWriteArraySet 是它的马甲 底层实现采用了 写入时拷贝 的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行,这时不影响其它线程的并发读,读写分离。 适合『读多写少』的应用场景
get 弱一致性
不要觉得弱一致性就不好
- 数据库的 MVCC 都是弱一致性的表现
- 并发高和一致性是矛盾的,需要权衡