1:进程与线程
1.1:进程
是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
1.2:线程
是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
二者对比: 进程可以拥有多个线程,进程拥有共享的资源,供内部线程调用。线程的切换比起进程的切换更加轻量级一些。
1.3:并行与并发
并发:一个核在一段时间内执行多个任务,在这些任务之间来回切换。在一个时刻,只有一个任务被执行。
并行:有多个核,可以同时处理多个任务,有并行的时候,一般也会有并发。除非任务数小于等于核心数。
串行: 每个核都是串行执行任务。
1.4:同步和异步
- 需要等待结果返回,才能继续运行就是同步
- 不需要等待结果返回,就能继续运行就是异
Java中可以使用线程,使得串行的方法(同步)执行变成异步执行。
1.5:效率
多核下使用线程可以提高效率
2:Java线程基础
2.1:线程的创建
2.1.1:Thread
thread来创建线程,实现run方法
继承thread类或者使用匿名内部类
public static void main(String[] args) {
Thread t1=new Thread("线程1"){
@Override
public void run() {
for(int i=0;i<6;i++){
log.debug("running...."+i);
}
}
};
t1.start();
log.debug("主线程");
}
输出:
10:58:02.097 [线程1] DEBUG 测试 - running....0
10:58:02.097 [main] DEBUG 测试 - 主线程
10:58:02.100 [线程1] DEBUG 测试 - running....1
10:58:02.100 [线程1] DEBUG 测试 - running....2
10:58:02.100 [线程1] DEBUG 测试 - running....3
10:58:02.100 [线程1] DEBUG 测试 - running....4
10:58:02.100 [线程1] DEBUG 测试 - running....5
2.1.2:Runnable
实现runnable的run方法,把runnable接口传给thread即可
// 创建任务对象
Runnable task2 = new Runnable() {
@Override
public void run() {
log.debug("hello");
}
};
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();
输出:
19:19:00 [t2] c.ThreadStarter - hello
Java 8 以后可以使用 lambda 精简代码
只有一个抽象方法就可以使用lambda
// 创建任务对象
Runnable task2 = () -> log.debug("hello");
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();
2.1.3:FutureTask<>
继承了runnable接口,实现了future接口,该接口支持了有返回结果的情况,泛型的类型就是要返回结果的类型。
配合FutureTask使用的Callable:
FutureTask的创建要传入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);
输出:19:22:27 [t3] c.ThreadStarter - hello
19:22:27 [main] c.ThreadStarter - 结果是:100
get方法
FutureTask的get方法用来获取返回值,而且是会阻塞直到获取成功。
2.2:线程运行原理
2.2.1:栈与栈帧
2.2.2:线程上下文切换
- 线程的切换伴随着栈帧的切换
2.3:线程常见方法
2.3.1:sleep方法与状态
- 功能是使线程休眠,让出cpu,但不会释放锁
- 是一个可中断方法,会抛出异常
- 睡眠结束后的线程未必会立刻得到执行
调用sleep方法会使线程进入Time_waiting(阻塞)状态,例如线程进入slepp后查看其状态
Thread t1=new Thread(new Runnable() {
@SneakyThrows
public void run() {
Thread.sleep(1000);
}
},"t1");
t1.start();
Thread.sleep(10);
log.debug(String.valueOf(t1.getState()));
输出:
20:49:34.998 [main] DEBUG 测试 - TIMED_WAITING
示例:sleep下响应中断示例
Thread t1=new Thread(new Runnable() {
@SneakyThrows
public void run() {
try{
Thread.sleep(2000);
}catch (InterruptedException e){
log.debug("响应中断");
}
}
},"t1");
t1.start();
Thread.sleep(500);
log.debug("进行中断");
t1.interrupt();
输出:
20:56:15.871 [main] DEBUG 测试 - 进行中断
20:56:15.873 [t1] DEBUG 测试 - 响应中断
2.3.2:yield方法
-
让出cpu使用权,从running进入runnable(就绪)状态
-
具体实现依赖于操作系统的任务调度器
2.3.3:线程优先级
-
越大优先级越高,但只是建议,具体还是根据任务调度器来。
-
cpu闲时,优先级几乎没有作用
-
etPriority可以设置优先级
2.3.4:join方法
- 等待另一个线程执行结束再执行本线程
- 应用于线程的同步
限时等待,join方法可以传入一个时间单位,作为最大等待时间,超过这个时间就不再等待
2.4:线程中断
2.4.1:interrupt方法
- 能够中断线程,线程的中断标志位被置位
- 阻塞状态的线程可以响应中断(例如执行sleep,wait,join方法的线程),响应中断后,线程的中断标志位置为假
示例:打断阻塞状态下的线程:
Thread t=new Thread(new Runnable() {
public void run() {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println("响应中断");
}
System.out.println("继续执行");
}
});
t.start();
Thread.sleep(1000);
t.interrupt();
Thread.sleep(100);
System.out.println("interrupt标志位:"+t.isInterrupted());
输出:
响应中断
继续执行
interrupt标志位:false
线程会捕获中断,并且将线程中断标志置为false
不再阻塞状态下进行打断,但阻塞状态下只要有interrupt为true这个标志,就会响应中断
Thread t=new Thread(new Runnable() {
public void run() {
System.out.println("非阻塞状态下打断自己");
Thread.currentThread().interrupt();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
System.out.println("还是能够捕获到这个中断中断");
}
System.out.println("继续执行");
}
});
t.start();
输出:
非阻塞状态下打断自己
还是能够捕获到i这个中断中断
继续执行
示例:打断正常运行(非阻塞状态)的线程:
Thread t=new Thread(new Runnable() {
public void run() {
while (true){
System.out.println("中断标志位:"+Thread.currentThread().isInterrupted());
System.out.println("running.....");
}
}
});
t.start();
t.interrupt();
t.interrupt();
输出
running.....
中断标志位:true
running.....
中断标志位:true
running.....
中断标志位:true
running.....
中断标志位:true
。。。。。
线程只是interrupt标志位被置为true,但是并不会停止线程,但是可以通过判断这个标志位来结束线程
应用:两阶段终止模式(优雅的结束一个线程)
Thread task;
public void start(){
task=new Thread(new Runnable() {
public void run() {
while(true){
Thread t=Thread.currentThread();
if(t.isInterrupted()){
System.out.println("结束线程");
break;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("有阻塞状态的情况,要重新设置标志位");
t.interrupt();
}
}
}
});
task.start();
}
public void end(){
task.interrupt();
}
2.4.2:interrupted方法与isInterrupted方法的区别
- interrupted方法:判断是否中断,但是会清除标记
- isInterrupted方法:判断是否中断,但是不会清除标记
2.5:主线程与守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
- 垃圾回收器线程就是一种守护线程
示例:守护线程
log.debug("主线程开始运行...");
Thread t1 = new Thread(new Runnable() {
@SneakyThrows
public void run() {
log.debug("守护线程开始运行...");
Thread.sleep(4000);
log.debug("守护线程运行结束...");
}
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
Thread.sleep(20);
log.debug("主线程运行结束...");
输出:
12:24:55.865 [main] DEBUG 测试 - 主线程开始运行...
12:24:55.868 [daemon] DEBUG 测试 - 守护线程开始运行...
12:24:55.895 [main] DEBUG 测试 - 主线程运行结束...
其他线程结束后,守护线程立即结束,不会再去执行后边的代码。例如上边输出中并没有打印守护线程运行结束。
2.6:线程的状态
2.6.1:操作系统层面:五种状态
- 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 【运行状态】指获取了 CPU 时间片运行中的状态
- 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
- 【阻塞状态】
- 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入阻塞状态】
- 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
- 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
- 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
2.6.2:java层面:六种状态
- NEW 线程刚被创建,但是还没有调用 start() 方法
- RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
- BLOCKED , WAITING , TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
- TERMINATED 当线程代码运行结束
3:共享模型
3.1:共享资源带来的问题
- 多个线程同时操作共享资源就可能发生问题
3.1.1:临界区
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
3.1.2:竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
3.2:synchronized
3.2.1:锁住代码块
它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
语法:
//同一时间只有一个线程能够获得该锁,进入该临界区
synchronized(对象) //
{
临界区
}
没有获取到锁就进入blocked状态
使用synchronized保证了临界区代码的原子性
3.2.2:锁加在方法上
静态方法
锁住类
public synchronized static void test() {
}
非静态方法
锁住对象
public synchronized void test() {
}
3.3:变量的线程安全分析
3.3.1:成员变量与静态变量
如果被共享且有修改操作,就是线程不安全的
3.3.2:局部变量
局部变量是线程安全的。但是局部变量应用的对象如果能够逃离该方法(参数传入),就可以发生安全问题
3.4:Mnitor概念
Java对象:大体可以分为三部分,对象头,实例数据和对齐填充
3.4.1:对象头
包含Mark Word和指向类的指针 (数组还会有数组长度)
普通对象:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组对象:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
3.4.2:Mark Word
结构如下:
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal(无锁) |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased(偏向锁) |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | 轻量 Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | 重量 Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|
3.4.3:monitor原理
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针
3.5:锁升级
3.5.1:轻量级锁
JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。
以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。
下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。
轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。
如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
3.5.2:锁膨胀
3.5.3:自旋优化
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
自旋失败就进入阻塞
3.5.4:偏向锁
重要:
偏向锁和轻量级锁都是要在多个线程不在同一时间段获取该锁时才有效,否则会升级为重量级锁
基本概念
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
偏向状态
回忆下mark word格式
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| hashcode:25 | age:4 | biased_lock:0 | 01 | Normal(无锁) |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | 01 | Biased(偏向锁) |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | 00 | 轻量 Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | 10 | 重量 Locked |
|-------------------------------------------------------|--------------------|
| | 11 | Marked for GC |
|-------------------------------------------------------|--------------------|
偏向锁默认会开启,但是有延迟。
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值 注意这里只是进入偏向状态,并不是已经加锁
偏向理解:
// 添加虚拟机参数 -XX:BiasedLockingStartupDelay=0
public static void main(String[] args) throws IOException {
Dog d = new Dog();
ClassLayout classLayout = ClassLayout.parseInstance(d);
new Thread(() -> {
log.debug("synchronized 前");
System.out.println(classLayout.toPrintableSimple(true));
synchronized (d) {
log.debug("synchronized 中");
System.out.println(classLayout.toPrintableSimple(true));
}
log.debug("synchronized 后");
System.out.println(classLayout.toPrintableSimple(true));
}, "t1").start();
输出:
11:08:58.117 c.TestBiased [t1] - synchronized 前
00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 中
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
11:08:58.121 c.TestBiased [t1] - synchronized 后
00000000 00000000 00000000 00000000 00011111 11101011 11010000 00000101
注意
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
这也就是偏向的含义,偏向于某一线程,下次这个线程需要获取这个锁可以直接进入(只有第一次需要cas操作)。但是如果有竞争,就会打破这个偏向状态,比如另一个线程也要获取这个锁,但是发现线程id不是自己。此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
禁用偏向锁
在上面测试代码运行时在添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁
调取hashCode()
正常状态对象一开始是没有 hashCode 的,第一次调用才生成
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销,就不会有偏向锁这一状态了
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
锁升级:偏向锁->轻量级锁
注意两个线程不在同一时间段获取该锁才升级为轻量级锁,否则会升级为重量级锁
- t1线程在时间T1获取该锁,为偏向锁,T3时间释放锁
- t2线程在T4时刻尝试获取该锁,发现该锁为偏向锁,而且线程ID不是自己,于是偏向锁失效,升级为轻量级锁
- 但是如果t2线程在T2时刻尝试获取该锁呢?那么会升级为重量级锁
调用wait/notify方法时
这时候必会升级为重量级锁,因为只有monitor才会有wait队列。
批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
注意这里的加锁解锁不是针对于对象,而是类
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
3.5.5:锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
3.5.6:锁粗化
例如一些循环的操作对同一个对象反复加锁解锁,jvm就会将锁扩大到整个循环
3.6:wait与notify
3.6.1:简介
注意:
wait方法进入wait队列,但是notify唤醒后不是直接获得锁,而是进入entry队列,等待锁。
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
它们都属于 Object 的一部分,而不属于 Thread。
只能用在同步方法或者同步控制块中使用,(也就是已经获得锁,没有获得锁不能使用这些方法)否则会在运行时抛出 IllegalMonitorStateException。
使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
3.6.2:API介绍
obj.wait()让进入 object 监视器的线程到 waitSet 等待,会释放锁obj.notify()在 object 上正在 waitSet 等待的线程中挑一个唤醒obj.notifyAll()让 object 上正在 waitSet 等待的线程全部唤醒
3.6.3:有限等待
wait方法可以传入时间,表示最大等待时间
3.6.4:sleep与wait及进入的状态
区别
- sleep 是 Thread 方法,而 wait 是 Object 的方法
- sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
- sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
状态
- sleep方法进入TIMED_WAITING
- wait(无时限)进入WAITING状态
- wait(有时限参数)进入TIMED_WAITING状态
3.7:线程状态转换
应该都是进入runnable状态,进入阻塞都是之后了。
3.8:死锁
两个线程互相拿着对方需要的锁,并且还等待对方释放锁,差不多这个意思
3.8.1:活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束,例如
public class TestLiveLock {
static volatile int count = 10;
static final Object lock = new Object();
public static void main(String[] args) {
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();
}
}
3.8.2:饥饿
是指有线程长期无法得到执行机会
3.9:ReentrantLock
4:java内存模型
看文档
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。 JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
4.1:可见性
使用volatile和syn
4.2:原子性
volatile不保证原子性
4.3:有序性-指令重排
使用volatile可以禁止指令重排