Java并发编程
1. 进程与线程
1.1 进程与线程
进程
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
线程
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
- Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器
二者对比
- 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
- 进程拥有共享的资源,如内存空间等,供其内部的线程共享
- 进程间通信较为复杂
- 同一台计算机的进程通信称为 IPC
- 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
- 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
1.2 并行与并发
单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是 同时运行 的 。总结为一句话就是: 微观串行,宏观并行
一般会将这种 线程轮流使用 CPU 的做法称为并发
引用 Rob Pike 的一段描述:
- 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
- 并行(parallel)是同一时间动手做(doing)多件事情的能力
2. Java线程
2.1 创建和运行线程
-
一:使用 Thread (继承Thread或匿名内部类重写run方法)
// 构造方法的参数是给线程指定名字,推荐 Thread t1 = new Thread("t1") { @Override // run 方法内实现了要执行的任务 public void run() { log.debug("hello"); } }; t1.start(); -
二:使用 Runnable 配合 Thread
把【线程】和【任务】(要执行的代码)分开
- Thread 代表线程
- Runnable 可运行的任务(线程要执行的代码)
Runnable task2 = new Runnable() { @Override public void run() { log.debug("hello"); } }; // 参数1 是任务对象; 参数2 是线程名字,推荐 Thread t2 = new Thread(task2, "t2"); t2.start();Java8以后可以使用lambda精简代码
// 创建任务对象 Runnable task2 = () -> log.debug("hello"); // 参数1 是任务对象; 参数2 是线程名字,推荐 Thread t2 = new Thread(task2, "t2"); t2.start();Thread 与 Runnable
public class Thread implements Runnable { /* Make sure registerNatives is the first thing <clinit> does. */ private static native void registerNatives(); static { registerNatives(); } private volatile String name; private int priority; private Thread threadQ; private long eetop; }小结:
- Runnable 接口把线程和任务分开了
- 用 Runnable 更容易与线程池等高级 API 配合
- 用 Runnable 让任务类脱离了 Thread 单继承体系,更灵活
-
三:FutureTask 配合 Thread
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);
2.2 原理-线程运行
栈与栈帧
Java 虚拟机栈
我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
线程上下文切换
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Context Switch 频繁发生会影响性能
2.3 常见方法
| 方法名 | static | 功能说明 | 注意 |
|---|---|---|---|
| start() | 启动一个新线程,在新的线程运行 run 方法中的代码 | start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现IllegalThreadStateException | |
| run() | 新线程启动后会调用的方法 | 如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为 | |
| join() | 等待线程运行结束 | ||
| join(long n) | 等待线程运行结束,最多等待 n毫秒 | ||
| getId() | 获取线程长整型的 id | id 唯一 | |
| getName() | 获取线程名 | ||
| setName(String) | 修改线程名 | ||
| getPriority() | 获取线程优先级 | ||
| setPriority(int) | 修改线程优先级 | java中规定线程优先级是1~10 的整数,较大的优先级能提高该线程被 CPU 调度的机率 | |
| getState() | 获取线程状态 | Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING,TIMED_WAITING, TERMINATED | |
| isInterrupted() | 判断是否被打断 | 不会清除 打断标记
|
|
| isAlive() | 线程是否存活(还没有运行完毕) | ||
| interrupt() | 打断线程 | 如果被打断线程正在 sleep,wait,join 会导致被打断的线程抛出 InterruptedException,并清除 打断标记 ;如果打断的正在运行的线程,则会设置 打断标记 ;park 的线程被打断,也会设置 打断标记 | |
| interrupted() | static | 判断当前线程是否被打断 | 会清除 打断标记
|
| currentThread() | static | 获取当前正在执行的线程 | |
| sleep(long n) | static | 让当前执行的线程休眠n毫秒,休眠时让出 cpu的时间片给其它线程 | |
| yield() | static | 提示线程调度器让出当前线程对CPU的使用 | 主要是为了测试和调试 |
-
start 与 run
结果都是执行了run方法中的代码,但是调用的线程不同
注意:
- 直接调用 run 是在主线程中执行了 run方法,没有启动新的线程
- 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
-
sleep 与 yield
sleep
- 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
- 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
- 睡眠结束后的线程未必会立刻得到执行
- 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性
yield
- 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
- 具体的实现依赖于操作系统的任务调度器
线程优先级
- 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
- 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
Runnable task1 = () -> { int count = 0; for (;;) { System.out.println("---->1 " + count++); } }; Runnable task2 = () -> { int count = 0; for (;;) { // Thread.yield(); // Thread.sleep(); System.out.println(" ---->2 " + count++); } }; Thread t1 = new Thread(task1, "t1"); Thread t2 = new Thread(task2, "t2"); // t1.setPriority(Thread.MIN_PRIORITY); // t2.setPriority(Thread.MAX_PRIORITY); t1.start(); t2.start(); -
join
static int r1 = 0; static int r2 = 0; public static void main(String[] args) throws InterruptedException { test2(); } private static void test2() throws InterruptedException { Thread t1 = new Thread(() -> { sleep(1); r1 = 10; }); Thread t2 = new Thread(() -> { sleep(2); r2 = 20; }); long start = System.currentTimeMillis(); t1.start(); t2.start(); t1.join(); t2.join(); long end = System.currentTimeMillis(); log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start); }分析如下
- 第一个 join:等待 t1 时, t2 并没有停止, 而在运行
- 第二个 join:1s 后, 执行到此, t2 也运行了 1s, 因此也只需再等待 1s
-
interrupt
打断 sleep,wait,join 的线程并重置中断状态为 false
测试发现
LockSupport.park()阻塞也会受中断状态的影响,即 interrupt 也能打断 park 中的线程,但是区别是其并不会重置中断状态为 false ,这就会导致打断一次后不使用interrupted来重置状态的话我们的park/unpark就没用了这几个方法都会让线程进入阻塞状态
打断 sleep 的线程, 会清空打断状态,以 sleep 为例
private static void test1() throws InterruptedException { Thread t1 = new Thread(()->{ sleep(1); }, "t1"); t1.start(); sleep(0.5); t1.interrupt(); log.debug(" 打断状态: {}", t1.isInterrupted()); }输出
java.lang.InterruptedException: sleep interrupted at java.lang.Thread.sleep(Native Method) at java.lang.Thread.sleep(Thread.java:340) at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386) at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8) at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59) at java.lang.Thread.run(Thread.java:745) 21:18:10.374 [main] c.TestInterrupt - 打断状态: false打断正常运行的线程
打断正常运行的线程, 不会清空打断状态
private static void test2() throws InterruptedException { Thread t2 = new Thread(()->{ while(true) { Thread current = Thread.currentThread(); boolean interrupted = current.isInterrupted(); if(interrupted) { log.debug(" 打断状态: {}", interrupted); break; } } }, "t2"); t2.start(); sleep(0.5); t2.interrupt(); }输出
20:57:37.964 [t2] c.TestInterrupt - 打断状态: true打断 park 线程
打断 park 线程, 不会清空打断状态
private static void test3() throws InterruptedException { Thread t1 = new Thread(() -> { log.debug("park..."); LockSupport.park(); log.debug("unpark..."); log.debug("打断状态:{}", Thread.currentThread().isInterrupted()); }, "t1"); t1.start(); sleep(0.5); t1.interrupt(); }输出
21:11:52.795 [t1] c.TestInterrupt - park... 21:11:53.295 [t1] c.TestInterrupt - unpark... 21:11:53.295 [t1] c.TestInterrupt - 打断状态:true如果打断标记已经是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可以使用
Thread.interrupted()清除打断状态,不然我们打断一次后park/unpark就没用了 -
不推荐方法
方法名 功能说明 stop() 停止线程运行 suspend() 挂起(暂停)线程运行 resume() 恢复线程运行
2.4 主线程与守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
例:
log.debug("开始运行...");
Thread t1 = new Thread(() -> {
log.debug("开始运行...");
sleep(2);
log.debug("运行结束...");
}, "daemon");
// 设置该线程为守护线程
t1.setDaemon(true);
t1.start();
sleep(1);
log.debug("运行结束...");
输出
08:26:38.123 [main] c.TestDaemon - 开始运行...
08:26:38.213 [daemon] c.TestDaemon - 开始运行...
08:26:39.215 [main] c.TestDaemon - 运行结束...
注意:
垃圾回收器线程就是一种典型的守护线程
毕竟,用户程序都运行结束了,还回收垃圾干嘛
2.5 Java线程状态
Java API层面对应线程有六种状态
-
NEW线程刚被创建,但是还没有调用start()方法 -
RUNNABLE当调用了start()方法之后,注意,Java API 层面的RUNNABLE状态涵盖了 操作系统 层面的【可运行状态】、【运行状态】和【阻塞状态】(由于BIO导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行) -
BLOCKED,WAITING,TIMED_WAITING都是 Java API 层面对【阻塞状态】的细分 -
TERMINATED当线程代码运行结束
反正这里需要注意的事 BLOCKED 和我们的两个 WAITING 状态分别对应的是 Monitor 中的 EntryList 和 WaitSet,也就是说处于
wait或者sleep状态的线程在 WaitSet 中,是没资格争夺锁的,但是 BLOCKED 状态的线程是可以去争夺锁的顺带说一句,synchronized 是非公平锁,也就是说不存在先来后到之说,只要有锁空闲,那么 EntryList 里面的线程谁抢到算谁的,当然,TIMED_WAITING 时间一到或者是
WAITING被唤醒那么第一时间也是去抢锁,抢不到就去 EntryList 里等下一次锁空闲
3. 共享模型--管程
操作系统使用信号量解决并发问题,Java选择使用管程(Monitor)解决并发问题。信号量和管程是等价的,可以使用信号量实现管程,也可以使用管程实现信号量。
管程就是指管理共享变量,以及对共享变量的相关操作。具体到 Java 语言中,管程就是管理类的成员变量和方法,让这个类是线程安全的。
3.1 共享问题
问题
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
而对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
临界区
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如,下面代码中的临界区
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
竞态条件
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
Java内存模型JMM
Java内存模型中规定所有共享变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对共享变量的操作(读取赋值等)必须在工作内存中进行,首先要将共享变量从主内存拷贝的自己的工作内存空间,然后对其进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
3.2 synchronized解决方案
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
首先我们使用阻塞式的解决方案 synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
-
如果synchronized加在一个类的普通方法上,那么相当于synchronized(this),即对对象加锁。
-
如果synchronized加载一个类的静态方法上,那么相当于synchronized(Class对象),即对类加锁。
3.3 变量的线程安全分析
成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
- 局部变量是线程安全的
- 但局部变量引用的对象则未必
- 如果该对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。
String、Integer等不可变类,由于其内部存放数据的属性都定义为final,因此是不可修改的,也就是说每次修改其实就是重新创建替换,因此是线程安全的。
3.4 Monitor概念
Java中的每个对象都与一个monitor(管程)关联,线程可以
lock或unlockmonitor。 一次只能有一个线程在monitor上持有锁。
对象头
HotSpot对象的内存布局
即:普通对象的对象头包含Mark Word和类型指针,如果是数组那就多一个数组长度这一项
至于Mark Word的结构的话,则是对应着不同锁状态有着不同内容
Monitor原理
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
执行 monitorenter 指令就是线程试图去获取 Monitor 的所有权,抢到了就是成功获取锁了;执行 monitorexit 指令则是释放了Monitor的所有权。
Monitor 结构如下
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入EntryList 中 BLOCKED
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的(谁抢到算谁的)
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但是后来被
wait或sleep等休眠的
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
WaitSet和EntryList区别?
- WaitSet 中的线程在没有被唤醒之前是没有权利去争夺锁的使用权的,被唤醒后可以去争夺锁,没争取到就待在 EntryList
- EntryList 中的线程每次都能去争夺线程使用权,其实就是尝试成为 Monitor 的 owner(因为synchronized是非公平锁)
ObjectMonitor
在HotSpot虚拟机中,Monitor是基于C++的ObjectMonitor类实现的,其主要成员包括:
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
- _EntryList:存放处于等待锁 block 状态的线程队列
- _count:约为WaitSet 和 _EntryList 的节点数之和
- _cxq: 多个线程争抢锁,会先存入这个单向链表
- _recursions: 记录重入次数
3.5 synchronized原理
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
对应字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
所以用人话解释一下synchronized的底层原理:
-
monitorenter线程执行
monitorenter指令时尝试获取monitor的所有权-
如果
monitor的记录数(ObjectMonitor的_recursions字段)为0,则该线程进入monitor,然后将记录数置为1,该线程即为monitor的所有者。 -
如果线程已经占有该
monitor(_owner指向当前线程),只是重新进入,则进入monitor的记录数加1。 -
如果其他线程已经占用了
monitor,则该线程进入阻塞状态(进入EntryList),直到monitor的进入数为0,再重新尝试获取monitor的所有权。
-
-
monitorexit执行
monitorexit的线程必须是monitor持有者指令执行时,
monitor的记录数减1,如果减1后记录数为0,那线程退出monitor,不再是这个monitor的持有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
注意:
方法级别的synchronized不会在字节码指令中有所体现,方法的同步并没有通过指令monitorenter和monitorexit来完成,不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor, 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
3.6 synchronized进阶
小故事
故事角色:
- 老王 - JVM
- 小南 - 线程
- 小女 - 线程
- 房间 - 对象
- 房间门上 - 防盗锁 - 重量级锁-MarkWord(正常情况下记录的事对象hashcode 和 gc 分代年龄) 被替换为Monitor地址
- 房间门上 - 小南书包 - 轻量级锁-MarkWord 被替换为锁记录地址
- 房间门上 - 刻上小南大名 - 偏向锁-MarkWord 记录 线程ID
- 批量重刻名 - 一个类的偏向锁撤销到达 20 阈值
- 不能刻名字 - 批量撤销该类对象的偏向锁,设置该类不可偏向
小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁,当上下文切换时,锁住门。这样,即使他离开了,别人也进不了门,他的工作就是安全的
但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的,小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?
小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进门前得翻翻书包,看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁门的方式
后来,小女回老家了,很长一段时间都不会用这个房间。小南每次还是挂书包,翻书包,虽然比锁门省事了,但仍然觉得麻烦。
于是,小南干脆在门上刻上了自己的名字:【小南专属房间,其它人勿用】,下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式
同学们都放假回老家了,小南就膨胀了,在 20 个房间刻上了自己的名字,想进哪个进哪个。后来他自己放假回老家了,这时小女回来了(她也要用这些房间),结果就是得一个个地擦掉小南刻的名字,升级为挂书包的方式。老王觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门上刻上自己的名字
后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。
轻量级锁对使用者是透明的,即语法仍然是 synchronized
假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
-
首先,还未加锁之前:
对象头中的 MarkWord 还是老样子,同时每个线程的栈帧中存在一个锁记录结构,此时两个是互无关系的两个结构
-
执行synchronized代码加锁:
让锁记录中 Object reference 指向锁对象,并尝试用 CAS自旋 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
-
CAS成功:
对象头的MarkWord为锁记录地址和状态00
栈帧中的锁记录为原来的MarkWord(HashCode ...01)和指向锁对象的引用
-
CAS失败:
-
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
-
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
-
-
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
注意,到这里的话,我们对 synchronized的可重入 就有三种解释(其实是两种,偏向和轻量是一样的)了:
-
重量级锁可重入原理就是加锁时如果线程已经占有该
monitor(_owner指向当前线程),则锁重入,即monitor的记录数字段加1。 -
如果是轻量锁可重入,则是加锁时CAS失败(因为 MarkWord 被换走了)并且指向自己,因此重入时在添加一条锁记录作为计数,新添加的锁记录取值为 null(注意这个重入锁记录取值为null很关键,这是我们判断是否还处于重入状态的依据)。
-
如果是偏向锁可重入,线程第一次成功加锁时,会在对象头和线程的栈帧中的锁记录中存储所偏向的线程id,重入时直接锁记录增加即可。
可以发现,偏向锁和轻量锁都要使用锁记录的,那么既然感觉差不多,偏向锁到底优化了啥?
前面我们说了,轻量锁每次都需要 CAS 操作来尝试替换MarkWord 再根据成功与否来决定是否添加锁记录,但是偏向锁尽管也有锁记录,却不需要再CAS替换了,只需要判断是否线程ID是自己,所以相对而言轻松很多
就这么看我也没感觉出重量锁相较于这两个有啥特别重的地方,那么重量锁到底重在哪?
我们知道重量锁和Monitor管程有关,其提供了各种复杂的操作,例如可以控制线程的唤醒阻塞、线程的阻塞队列等等,而我们的轻量和偏向从实现方式来看貌似不具有这种能力,当然,重量锁能够如此强大,自然是需要消耗更多的性能,因为需要借助系统的内核功能,同时维护这么多东西也需要消耗性能!
可是为什么我反正都是加的synchronized关键字,还是能用上重量锁的全部功能例如阻塞、唤醒呢?
废话,你喵的锁用那些方法的时候就说明有线程出现竞争,这时候锁已经是膨胀成重量锁了
-
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:
只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。
注意
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
一个对象创建时:
- 如果开启了偏向锁(默认开启),那么对象创建后,markword最后 3 位为 101
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 -XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到hashcode 时才会赋值
偏向锁撤销:
- 调用hashcode:偏向锁的对象 MarkWord 中存储的是线程 id,如果调用hashCode 会导致偏向锁被撤销(因为hashcode无了)
- 其它线程使用对象:当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
-
调用wait/notify:当线程调用
wait/notify时,证明已经不是一个线程在使用锁了,当然会锁膨胀,撤销偏向锁
批量重定向:
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
批量撤销:
当撤销偏向 锁阈值超过 40 次后,Jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
3.7 Jvm锁优化
锁消除
锁消除就是字面意思,虚拟机会根据自己的代码检测结果取消一些加锁逻辑。虚拟机通过检测会发现一些代码中不可能出现数据竞争,但是代码中又有加锁逻辑,为了提高性能,就消除这些锁。如果一段代码中,在堆上的所有数据都不会被其他线程访问到,那就可以把它们当成线程私有数据,自然就不需要同步加锁了。
锁粗化
对相同对象多次加锁,导致线程发生多次重入,可以使用锁粗化方式来优化,这里不是锁膨胀那种改变锁,而是改变范围
原代码
public void method(String s1, String s2){
synchronized(this){
System.out.println("s1");
}
synchronized(this){
System.out.println("s1");
}
}
锁粗化
public void method(String s1, String s2){
synchronized(this){
System.out.println("s1");
// }
// synchronized(this){
System.out.println("s1");
}
}
3.8 Wait/Notify
API 介绍:
-
obj.wait()让进入 object 监视器的线程到 waitSet 等待 -
obj.notify()在 object 上正在 waitSet 等待的线程中挑一个唤醒 -
obj.notifyAll()让 object 上正在 waitSet 等待的线程全部唤醒
wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止wait(long n) 有时限的等待, 到 n 毫秒后结束等待,或是被 notify
原理:
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争
这么看来,WAITING状态还是要比BLOCKED低一点
sleep(long n) 和 wait(long n) 的区别:
- sleep 是 Thread 方法,而 wait 是 Object 的方法
- sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
- sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
- 它们的状态 TIMED_WAITING
3.9 Park/Unpark
LockSupport 类中的方法
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
可以先park再unpark
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(1);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
},"t1");
t1.start();
sleep(2);
log.debug("unpark...");
LockSupport.unpark(t1);
也可以先unpark再park
Thread t1 = new Thread(() -> {
log.debug("start...");
sleep(2);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
}, "t1");
t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);
特点
与 Object 的 wait/notify 相比
- wait,notify 和 notifyAll 必须配合锁对象一起使用,而 park,unpark 不必(自己内部有一个互斥锁
_mutex) - park和unpark以线程为单位精准阻塞和唤醒线程,notify是随机唤醒一个线程,而notifyAll唤醒所有的等待线程
- park/unpark 可以先 unpark,而 wait/notify 不能先 notify
原理
每个线程都有自己的一个 Parker 对象,由三部分组成 _counter标志 , _cond阻塞队列 和 _mutex互斥锁
- 调用park的时候,线程看一下
_counter是不是0,是0,就阻塞在_cond里,并再赋值一下0给_counter,
调用unpark,将_counter赋值为1,并唤醒_cond里的线程,然后再把_counter置为0,线程恢复运行。 - 先调用unpark的情况,先置为1,再调用park时,发现
_counter是1,不需要阻塞,继续运行,并把_counter置为0。
3.10 interrupt/isInterrupted()
interrupt
Thread.interrupt() 方法: 作用是改变线程的中断状态位,即设置为 true。
interrupt()方法只是改变中断状态,不会中断一个正在运行的线程。
如果线程被 Object.wait Thread.join和 Thread.sleep 三种方法之一阻塞,此时调用该线程的 interrupt() 方法,那么该线程将抛出一个 InterruptedException 中断异常(该线程必须事先预备好处理此异常),从而提早地终结被阻塞状态。如果线程没有被阻塞,这时调用 interrupt()将不起作用,直到执行到 wait() sleep() join()时,才马上会抛出 InterruptedException。
注意这里的 interrupt() 只是改变中断状态位,而我们的
waitjoinsleep会感知这个状态位,一旦变为 true 就抛出异常,并把状态为该回false从而导致对应的阻塞状态终止,同时wait是与锁有关的,被终端不意味着马上就能执行接下来的代码,还是要去monitor的EntryList里抢夺锁
isInterrupted
this.interrupted():测试当前线程是否已经中断(静态方法)并且将状态位置为 false
this.isInterrupted():测试线程是否已经中断,但是不能清除状态标识。
3.11 线程状态变化
NEW --> RUNNABLE
- 当调用
t.start()方法时,由 NEW --> RUNNABLE
RUNNABLE <--> WAITING
-
线程用
synchronized(obj)获取了对象锁后- 调用
obj.wait()方法时,t 线程从 RUNNABLE --> WAITING,线程进入WaitSet - 调用
obj.notify(),obj.notifyAll(),t.interrupt()时- 竞争锁成功,t 线程从 WAITING --> RUNNABLE,成为锁的owner
- 竞争锁失败,t 线程从 WAITING --> BLOCKED,待在EntryList
- 调用
-
当前线程调用
join()方法时,当前线程从 RUNNABLE --> WAITING- 注意是当前线程在t 线程对象的监视器上等待
线程运行结束,或调用了当前线程的
interrupt()时,当前线程从 WAITING --> RUNNABLE -
当前线程调用
LockSupport.park()方法会让当前线程从 RUNNABLE --> WAITING
RUNNABLE <--> TIMED_WAITING
- 线程用
synchronized(obj)获取了对象锁后- 调用
obj.wait(long n)方法时,线程从 RUNNABLE --> TIMED_WAITING - 线程等待时间超过了 n 毫秒,或调用
obj.notify()、obj.notifyAll(),t.interrupt()时- 竞争锁成功,线程从 TIMED_WAITING --> RUNNABLE
- 竞争锁失败,线程从 TIMED_WAITING --> BLOCKED
- 调用
- 当前线程调用
t.join(long n)方法时,当前线程从 RUNNABLE --> TIMED_WAITING - 当前线程调用
Thread.sleep(long n),当前线程从 RUNNABLE --> TIMED_WAITING
当前线程等待时间超过了 n 毫秒,当前线程从 TIMED_WAITING --> RUNNABLE
RUNNABLE <--> BLOCKED
线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE --> BLOCKED
RUNNABLE <--> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
3.12 线程活跃性
死锁
线程死锁是指两个或两个以上的线程互相持有对方所需要的资源,由于互斥锁的特性,一个线程持有一个资源,或者说获得一个锁,在该线程释放这个锁之前,其它线程是获取不到这个锁的,而且会一直死等下去,因此这便造成了死锁。
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束
饥饿
一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束
3.13 ReentrantLock
相对于 synchronized 它具备如下特点
-
可中断
t1.interrupt() -
可以设置超时时间
lock.tryLock(1, TimeUnit.SECONDS) -
可以设置为公平/非公平锁
默认非公平,可设置公平
ReentrantLock lock = new ReentrantLock(false); -
支持多个条件变量
ReentrantLock 支持多个条件变量的,即我们可以定义多个条件变量,类似多个WaitSet,这样我们就能对锁住的线程分开唤醒了
与 synchronized 一样,都支持可重入
基本语法
// 获取锁
reentrantLock.lock();
try {
// 临界区
} finally {
// 释放锁
reentrantLock.unlock();
}
4. 共享模型--内存
4.1 Java内存模型
JMM 即Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
Java内存模型说白了就是一个规范:
规定了所有共享变量都存储在主内存(堆内存),主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存(栈内存)中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
4.2 原子性和可见性
原子性问题:首先我们需要指导,一条程序指令的原子性是不需要我们担心的,即它不会受到多线程或者其它条件的干扰,但是代码块或 i++ 这样的执行了多条指令的代码的原子性就无法保证了,我们可以使用 synchronized 来保证,但是有一说一,太重了。
可见性问题:当两个线程都在使用同一个变量的时候,由于 JIT 有时会将频繁使用的变量值缓存到自己的工作内存中减少对主存中变量的访问,因此在另一个线程修改了变量之后,可能导致当前线程没有能够及时获取到变量的最新取值。
解决方法:volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
注意,我们使用System.out.println()之后其实就和加上了 volatile 关键字一样的效果。此外,synchronized 也是可以保证可见性的。
4.3 有序性
JVM 会在不影响正确性的前提下,调整语句的执行顺序
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
上面代码,无论是先执行i = xx还是j = xx都没啥影响,因此最终底层对这两段代码的执行顺序是不确定的!
这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。
现代处理器会设计将指令划分为了更小的阶段,例如
取指令读指令等,在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行
解决方法:还是他喵的 volatile 这个关键字结合 CAS 简直不要太好用
4.4 volatile原理
前面说了 volatile 可以保证代码的有序性和可见性,注意没有原子性哈,那个是要靠加锁来保证的
volatile底层原理:内存屏障
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
如何保证可见性?
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
如何保证有序性?
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
4.5 happen-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结。包含了以下规则,同时离开了下列规则的话,Jvm 并不能保证一个共享变量的写对另一个共享变量可见
程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变
管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见
线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见
线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断
传递性规则:这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)
对象终结规则:这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。
4.6 线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用getInstance)时的线程安全,并思考注释中的问题
饿汉式:类加载就会导致该单实例对象被创建
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建
实现一:
// 问题1:为什么加 final
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
public final class Singleton implements Serializable {
// 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
private Singleton() {}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全?
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
实现二:
// 问题1:枚举单例是如何限制实例个数的
// 问题2:枚举单例在创建时是否有并发问题
// 问题3:枚举单例能否被反射破坏单例
// 问题4:枚举单例能否被反序列化破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
enum Singleton {
INSTANCE;
}
实现三:
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
// 分析这里的线程安全, 并说明有什么缺点
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
实现四:DCL
public final class Singleton {
private Singleton() { }
// 问题1:解释为什么要加 volatile ?
private static volatile Singleton INSTANCE = null;
// 问题2:对比实现3, 说出这样做的意义
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
实现五:
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
5. 共享模型--无锁
问题:
我们有一个 Amount 金额类,其中有一个 withdraw 扣款方法,该方法调用时执行 balance -= amount 命令,咋一看没问题,毕竟也就一行代码,但是多线程场景下是会有问题的!
我们对该行代码的字节码指令进行分析,首先需要将 balance 和 amount 装载进操作数栈,运算完成后再写回局部变量表。
了解了字节码指令就能看出什么问题了,多线程下,肯定会发生指令交错执行的情况,这样最终得到的数据结果就会有异常。
解决思路:
- 锁:这个就不用多说,加锁就完事了,前面也说过,volatile可以保证可见性和有序性,而代码的原子性就可以加锁来保证了
- 无锁:不加锁还能有什么方法,不用想,肯定是底层用C++等语言实现了某种方法
5.1 CAS 与 volatile
CAS
CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。CAS 操作包含三个操作数 -- 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值相比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。在 Java 中,Java 并没有直接实现 CAS,而是通过 C++ 内联汇编的形式实现的。然后Java通过本地方法栈来调用。
volatile
volatile 前面不是讲过了么?为什么这里又提到了?因为我们的 CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果。
无锁--效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而
synchronized会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。 - 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,虽然不会进入阻塞,但可能由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
CAS 特点
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,再重试。
synchronized 是基于悲观锁的思想:防着其它线程来修改共享变量,使用时不允许任何线程访问
CAS 体现的是无锁并发、无阻塞并发
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5.2 原子整数
J.U.C 并发包提供了:AtomicBoolean AtomicInteger AtomicLong
以 AtomicInteger 为例
AtomicInteger i = new AtomicInteger(0);
// 获取并自增(i = 0, 结果 i = 1, 返回 0),类似于 i++
System.out.println(i.getAndIncrement());
// 自增并获取(i = 1, 结果 i = 2, 返回 2),类似于 ++i
System.out.println(i.incrementAndGet());
// 自减并获取(i = 2, 结果 i = 1, 返回 1),类似于 --i
System.out.println(i.decrementAndGet());
// 获取并自减(i = 1, 结果 i = 0, 返回 1),类似于 i--
System.out.println(i.getAndDecrement());
// 获取并加值(i = 0, 结果 i = 5, 返回 0)
System.out.println(i.getAndAdd(5));
// 加值并获取(i = 5, 结果 i = 0, 返回 0)
System.out.println(i.addAndGet(-5));
// 获取并更新(i = 0, p 为 i 的当前值, 结果 i = -2, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.getAndUpdate(p -> p - 2));
// 更新并获取(i = -2, p 为 i 的当前值, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.updateAndGet(p -> p + 2));
// 获取并计算(i = 0, p 为 i 的当前值, x 为参数1, 结果 i = 10, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
// getAndUpdate 如果在 lambda 中引用了外部的局部变量,要保证该局部变量是 final 的
// getAndAccumulate 可以通过 参数1 来引用外部的局部变量,但因为其不在 lambda 中因此不必是 final
System.out.println(i.getAndAccumulate(10, (p, x) -> p + x));
// 计算并获取(i = 10, p 为 i 的当前值, x 为参数1, 结果 i = 0, 返回 0)
// 其中函数中的操作能保证原子,但函数需要无副作用
System.out.println(i.accumulateAndGet(-10, (p, x) -> p + x));
5.3 原子引用
原子引用:AtomicReference AtomicMarkableReference AtomicStampedReference
我们需要解决并发问题的操作不只是对整数的修改,有时我们的对象修改读取也是要进行原子操作的,例如我们的数值封装类或者 BigDecimal 因此我们可以使用原子引用
示例:
AtomicReference<BigDecimal> ref = new AtomicReference<>();
ref.compareAndSet(new BigDecimal(1),new BigDecimal(2));
5.4 ABA 问题
问题:什么叫 ABA 问题?顾名思义,由 A 到 B 再到 A ,我们前面说过,CAS 本质是比较再替换,那么这里的 A 已经被人修改两次了,我们直接 CAS 是感知不到的。
解决方法:对要修改的值添加一个版本号,每次修改就让版本号同时改变,AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如: A - B - A - C ,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了 AtomicMarkableReference
5.5 原子数组
原子数值:AtomicIntegerArray AtomicLongArray AtomicReferenceArray
5.6 字段更新器
字段更新器:AtomicReferenceFieldUpdater AtomicIntegerFieldUpdater AtomicLongFieldUpdater
利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常
Exception in thread "main" java.lang.IllegalArgumentException: Must be volatile type
示例:
public class Test5 {
private volatile int field;
public static void main(String[] args) {
AtomicIntegerFieldUpdater fieldUpdater =
AtomicIntegerFieldUpdater.newUpdater(Test5.class, "field");
Test5 test5 = new Test5();
fieldUpdater.compareAndSet(test5, 0, 10);
// 修改成功 field = 10
System.out.println(test5.field);
// 修改成功 field = 20
fieldUpdater.compareAndSet(test5, 10, 20);
System.out.println(test5.field);
// 修改失败 field = 20
fieldUpdater.compareAndSet(test5, 10, 30);
System.out.println(test5.field);
}
}
5.7 原子累加器
阿里《Java开发手册》嵩山版提到过这样一条建议:
【参考】volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题,但是如果多写,同样无法解决线程安全问题。
说明:如果是count++操作,使用如下类实现:AtomicInteger count = new AtomicInteger(); count.addAndGet(1);
如果是 JDK8,推荐使用 LongAdder 对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)
以上内容共有两个重点:
- 类似于
count++这种非一写多读的场景使用 volatile 解决不了并发问题; - 如果是 JDK8 推荐使用
LongAdder而非AtomicLong来替代volatile,因为LongAdder的性能更好。
LongAdder与AtomicLong分析
此处省略
性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加Cell[1]... 最后将结果汇总。这样它们在累加时操作的不同的 Cell 变量,因此减少了 CAS 重试失败,从而提高性能。
6. 共享模型--不可变
问题:
下面的代码在运行时,由于 SimpleDateFormat 不是线程安全的
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}
有很大几率出现 java.lang.NumberFormatException 或者出现不正确的日期解析结果,因为指令并不是原子性的,多线程指令交错会出现问题,其它非线程安全的数据结构的并发问题也多是此类原因
解决:
- 同步锁:首先我们可能想到的是加锁来保证整个过程的原子性,但是毫无疑问,对性能有很大损失
- 不可变:如果一个对象不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改
不可变设计
我们熟知的 String 还有其它封装类都是不可变设计
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
// ...
}
可以看见,String 内部是一个使用 final 修饰的字符数组(Jdk 9变成了 byte 数组)
final 的使用
- 属性用
final修饰保证了该属性是只读的,不能修改 - 类用
final修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
保护性拷贝:
我们使用 String 的时候还是用上了很多修改的方法,可是前面明明说了 String 是不可变的,那是怎么回事?其实在构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝】
final 关键字底层原理
final 变量的设置与获取原理和 volatile 关键字类似,都会添加上读屏障写屏障
无状态
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
7. 线程池
线程池的优势:
- 降低系统资源消耗,通过重用已存在的线程,降低线程创建和销毁造成的消耗;
- 提高系统响应速度,当有任务到达时,通过复用已存在的线程,无需等待新线程的创建便能立即执行;
- 方便线程并发数的管控。节省资源。
如何创建线程池:
Java 中创建线程池有以下两种方式:
- 通过
ThreadPoolExecutor类创建(推荐) - 通过
Executors类创建
其实这两种方式在本质上是一种方式,都是通过 ThreadPoolExecutor 类的方式创建,因为 Executors 类调用了 ThreadPoolExecutor 类的方法。
7.1 ThreadPoolExecutor
创建一个线程池:
public class MyThreadPool {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(2, 4, 10, TimeUnit.SECONDS,new ArrayBlockingQueue<>(20), new ThreadPoolExecutor.CallerRunsPolicy());
for (int i = 1; i 5; i++) {
// 创建WorkerThread对象
Runnable worker = new Runnable(()->{});
// 执⾏任务
executor.execute(worker);
}
}
}
构造方法:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
-
corePoolSize核心线程数目 (最多保留的线程数) -
maximumPoolSize最大线程数目 -
keepAliveTime生存时间 - 针对救急线程 -
unit时间单位 - 针对救急线程 -
workQueue阻塞队列,无剩余线程时,新加入的任务进入此队列等待常用的阻塞队列: ArrayBlockingQueue; LinkedBlockingQueue; SynchronousQueue; -
threadFactory线程工厂 - 一般使用默认的线程创建工厂的方法Executors.defaultThreadFactory()来创建线程。 -
handler拒绝策略,阻塞队列已满并且无法创建新线程,就执行对应的拒绝策略
工作方式:
-
线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
-
当线程数达到
corePoolSize并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue队列排队,直到有空闲的线程。 -
如果队列选择了有界队列,那么任务超过了队列大小时,会创建
maximumPoolSize - corePoolSize数目的线程来救急。 -
如果线程到达
maximumPoolSize仍然有新任务这时会执行拒绝策略。拒绝策略 Jdk 提供了 4 种实现方式。-
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出异常。 -
ThreadPoolExecutor.DiscardPolicy:丢弃任务但不抛出异常。 -
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务 -
ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务
-
-
当高峰过去后,超过
corePoolSize的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由keepAliveTime和unit来控制。 -
核心线程是不会自动释放的
提交任务:
// 执行任务
void execute(Runnable command)
// 提交任务 task,用返回值 Future 获得任务执行结果
<T> Future<T> submit(Callable<T> task)
// 提交 tasks 中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks)
// 提交 tasks 中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)
// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks)
// 提交 tasks 中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其它任务取消,带超时时间
<T> T invokeAny(Collection<? extends Callable<T>> tasks,long timeout, TimeUnit unit)
关闭线程池:
/*
线程池状态变为 SHUTDOWN
- 不会接收新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
void shutdown();
/*
线程池状态变为 STOP
- 不会接收新任务
- 会将队列中的任务返回
- 并用 interrupt 的方式中断正在执行的任务
*/
List<Runnable> shutdownNow();
什么?不是说
interrupt只是改变中断状态位吗?一般waitsleepjoin都会感知该状态位(其实park也可以,但是只有它检测到状态位改变后不会给人改成false),所以使用interrupt能够中断这些操作,但是为什么还能中断正在执行的线程?前面说过,interrupt的出现能够让我们实现体面的停止线程,即我们可以一直检测状态位,如果状态位改变了就执行对应的善后工作再手动break;
查看 ThreadPoolExecutor 的执行任务的代码方法 runWorker
可以看出,的确是在一个 while 循环中检测我们的线程池的调度线程(对于线程池来说就是主线程)的状态位,如果调度线程被 interrupt 打断了,确保其中的任务线程都处于打断状态
7.2 Executors
-
newFixedThreadPool定长线程池public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }特点:
- 核心线程数 == 最大线程数(没有救急线程被创建),因此也无需超时时间
- 阻塞队列是无界的,可以放任意数量的任务
评价:适用于任务量已知,相对耗时的任务
-
newCachedThreadPool无限长线程池public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }特点:
- 核心线程数是 0, 最大线程数是
Integer.MAX_VALUE,救急线程的空闲生存时间是 60s - 队列采用了 SynchronousQueue 实现特点是,它没有容量,没有线程来取是放不进去的,可近似看作没有阻塞队列,来者不拒,空闲释放
评价:整个线程池表现为线程数会根据任务量不断增长,没有上限,当任务执行完毕,空闲 1分钟后释放线程。 适合任务数比较密集,但每个任务执行时间较短的情况
- 核心线程数是 0, 最大线程数是
-
newSingleThreadExecutor单线程执行public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }使用场景:
希望多个任务排队执行。线程数固定为 1,任务数多于 1 时,会放入阻塞队列排队。任务执行完毕,这唯一的线程也不会被释放,这样可以保证所有的任务按序执行。 -
newScheduledThreadPool定长定时周期任务public static ScheduledExecutorService newScheduledThreadPool( int corePoolSize, ThreadFactory threadFactory) { return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory); }定长线程池,用来执行一些定时任务、周期任务
-
newWorkStealingPool这个是创建的
ForkJoinPool
7.3 Fork/Join
概念Fork/Join 是 JDK 1.7 加入的新的线程池实现,它体现的是一种分治思想,适用于能够进行任务拆分的 cpu 密集型运算
所谓的任务拆分,是将一个大任务拆分为算法上相同的小任务,直至不能拆分可以直接求解。跟递归相关的一些计算,如归并排序、斐波那契数列、都可以用分治思想进行求解Fork/Join 在分治的基础上加入了多线程,可以把每个任务的分解和合并交给不同的线程来完成,进一步提升了运算效率Fork/Join 默认会创建与 cpu 核心数大小相同的线程池
使用
提交给 Fork/Join 线程池的任务需要继承 RecursiveTask(有返回值)或 RecursiveAction(没有返回值)
例如下面定义了一个对 1~n 之间的整数求和的任务
class AddTask1 extends RecursiveTask<Integer> {
int n;
public AddTask1(int n) {
this.n = n;
}
@Override
public String toString() {
return "{" + n + '}';
}
@Override
protected Integer compute() {
// 如果 n 已经为 1,可以求得结果了
if (n == 1) {
log.debug("join() {}", n);
return n;
}
// 将任务进行拆分(fork)
AddTask1 t1 = new AddTask1(n - 1);
t1.fork();
log.debug("fork() {} + {}", n, t1);
// 合并(join)结果
int result = n + t1.join();
log.debug("join() {} + {} = {}", n, t1, result);
return result;
}
}
然后提交给 ForkJoinPool 来执行
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool(4);
System.out.println(pool.invoke(new AddTask1(5)));
}
参考教程