文章目录
并发编程
前言
本笔记主要依托于B站满老师的视频,参考GitHub上博主的资料以及自己所阅读的书籍。如果本文对你有帮助的话,请大家多多给满老师一键三连,给GitHub的仓库Star,谢谢。
参考视频:https://www.bilibili.com/video/BV16J411h7Rd
参考文档:https://github.com/Seazean/JavaNote/blob/main/Prog.md
1.进程与线程
1.1.概述
进程:程序是静止的,进程实体的运行过程就是进程,是系统进行资源分配的基本单位。
- 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
- 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
- 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)
进程的特征:并发性、异步性、动态性、独立性、结构性
线程:线程是属于进程的,是一个基本的 CPU 执行单元,是程序执行的最小单元。线程是进程中的一个实体,是系统独立调度的基本单位,线程本身不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。
- 一个进程之内可以分为一到多个线程。
- 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行。
- Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器。
线程的作用:使多道程序更好的并发执行,提高资源利用率和系统吞吐量,增强操作系统的并发性能。
关系:一个进程可以包含多个线程,这就是多线程,比如看视频是进程,图画、声音、广告等就是多个线程
并发并行:
- 并行:在同一时刻,有多个指令在多个 CPU 上同时执行
- 并发:在同一时刻,有多个指令在单个 CPU 上交替执行
同步异步:
- 需要等待结果返回,才能继续运行就是同步
- 不需要等待结果返回,就能继续运行就是异步
笔记的整体结构依据视频编写,并随着学习的深入补充了很多知识
1.2.对比
线程进程对比:
-
进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
-
进程拥有共享的资源,如内存空间等,供其内部的线程共享
-
进程间通信较为复杂
- 同一台计算机的进程通信称为 IPC(Inter-process communication)
- 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
-
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低
-
线程通信相对简单,因为线程之间共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
IPC扩展:
- 信号量:信号量是一个计数器,用于多进程对共享数据的访问,解决同步相关的问题并避免竞争条件。
- 共享存储:多个进程可以访问同一块内存空间,需要使用信号量用来同步对共享存储的访问。
- 管道通信:管道是用于连接一个读进程和一个写进程以实现它们之间通信的一个共享文件 pipe 文件,该文件同一时间只允许一个进程访问,所以只支持半双工通信。
- 匿名管道(Pipes):用于具有亲缘关系的父子进程间或者兄弟进程之间的通信
- 命名管道(Names Pipes):以磁盘文件的方式存在,可以实现本机任意两个进程通信,遵循 FIFO
- 消息队列:内核中存储消息的链表,由消息队列标识符标识,能在不同进程之间提供全双工通信,对比管道:
- 匿名管道存在于内存中的文件;命名管道存在于实际的磁盘介质或者文件系统;消息队列存放在内核中,只有在内核重启(操作系统重启)或者显示地删除一个消息队列时,该消息队列才被真正删除
- 读进程可以根据消息类型有选择地接收消息,而不像 FIFO 那样只能默认地接收
- 套接字:与其它通信机制不同的是,可用于不同机器间的互相通信
Java 中的通信机制:volatile、等待/通知机制、join 方式、InheritableThreadLocal、MappedByteBuffer
2.并行与并发
单核 cpu 下,线程实际还是串行执行
的。操作系统中有一个组件叫做任务调度器
,将 cpu 的时间片
(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于 cpu 在线程间(时间片很短)的切换非常快,人类感觉是同时运行的
。总结为一句话就是: 微观串行,宏观并行
,一般会将这种 线程轮流使用 CPU 的做法称为并发, concurrent``。
多核 cpu下,每个 核(core) 都可以调度运行线程,这时候线程可以是并行的。
引用 Rob Pike(golang 语言的创造者) 的一段描述:
- 并发(concurrent)是同一时间应对(dealing with)多件事情的能力
- 并行(parallel)是同一时间动手做(doing)多件事情的能力
例子
- 家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
- 家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
- 雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
3.同步与异步
以调用方角度来讲,如果
- 需要等待结果返回,才能继续运行就是同步
- 不需要等待结果返回,就能继续运行就是异步
3.1.应用之异步调用
-
设计:多线程可以让方法执行变为异步的(即不要巴巴干等着)比如说读取磁盘文件时,假设读取操作花费了 5 秒钟,如果没有线程调度机制,这 5 秒 cpu 什么都做不了,其它代码都得暂停…
-
结论:
- 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
- tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
- ui 程序中,开线程进行其他操作,避免阻塞 ui 线程
-
代码演示
/** * 同步等待 */ @Slf4j(topic = "c.Sync") public class Sync { public static void main(String[] args) { FileReader.read(Constants.MP4_FULL_PATH); log.debug("do other things ..."); } }
/** * 异步不等待 */ @Slf4j(topic = "c.Async") public class Async { public static void main(String[] args) { new Thread(() -> FileReader.read(Constants.MP4_FULL_PATH)).start(); log.debug("do other things ..."); } }
3.2.应用之提高效率
充分利用多核 cpu 的优势,提高运行效率。想象下面的场景,执行 3 个计算,最后将计算结果汇总。
- 如果是串行执行,那么总共花费的时间是
10 + 11 + 9 + 1 = 31ms
- 但如果是四核 cpu,各个核心分别使用线程 1 执行计算 1,线程 2 执行计算 2,线程 3 执行计算 3,那么 3 个线程是并行的,花费时间只取决于最长的那个线程运行的时间,即
11ms
最后加上汇总时间只会花费12ms
注意
需要在多核 cpu 才能提高效率,单核仍然时是轮流执行
- 设计:使用多线程充分利用 CPU
- 环境搭建:
- 基准测试工具选择,使用了比较靠谱的 JMH,它会执行程序预热,执行多次测试并平均
- cpu 核数限制,有两种思路
1、使用虚拟机,分配合适的核
2、 使用 msconfig,分配合适的核,需要重启比较麻烦 - 并行计算方式的选择
1、最初想直接使用 parallel stream,后来发现它有自己的问题
2、改为了自己手动控制 thread,实现简单的并行计算
- 代码测试:TODO
- 结论:
- 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
- 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
- 1)有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
- 2)也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
- IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化
4.Java线程
4.1.创建和运行线程
-
方法一:直接使用Thread
// 创建线程对象,采用匿名内部类的方法,覆盖run方法 Thread t = new Thread() { public void run() { // 要执行的任务 } }; // 启动线程,将线程交给任务调度器,分配时间片 t.start();
// 构造方法的参数是给线程指定名字,推荐 Thread t1 = new Thread("t1") { @Override // run 方法内实现了要执行的任务 public void run() { log.debug("hello"); } }; t1.start();
代码演示:
/** * @Author: ZhangLu In DLUT * @since: 1.0.0 * @Description: */ @Slf4j(topic = "c.Test1") public class MyTest1 { public static void main(String[] args) { // 采用匿名内部类创建线程 Thread t = new Thread(){ @Override public void run() { log.debug("running"); } }; // 给线程起名字 t.setName("t1"); // 启动线程 t.start(); // 主线程打印 log.debug("running"); } }
-
方法二:直接使用Thread
把【线程】和【任务】(要执行的代码)分开- Thread 代表线程
- Runnable 可运行的任务(线程要执行的代码)
Runnable runnable = new Runnable() { public void run(){ // 要执行的任务 } }; // 创建线程对象 Thread t = new Thread( runnable ); // 启动线程 t.start();
代码演示:
/** * @Author: ZhangLu In DLUT * @since: 1.0.0 * @Description: */ @Slf4j(topic = "c.Test2") public class MyTest2 { public static void main(String[] args) { Runnable r = new Runnable() { @Override public void run() { log.debug("running"); } }; Thread t1 = new Thread(r, "t1"); t1.start(); } }
Java 8 以后可以使用 lambda 精简代码(针对函数式接口)// 函数式接口 @FunctionalInterface public interface Runnable { /** * When an object implementing interface <code>Runnable</code> is used * to create a thread, starting the thread causes the object's * <code>run</code> method to be called in that separately executing * thread. * <p> * The general contract of the method <code>run</code> is that it may * take any action whatsoever. * * @see java.lang.Thread#run() */ public abstract void run(); }
精简后的代码如下:
@Slf4j(topic = "c.Test2") public class MyTest2 { public static void main(String[] args) { // 单行代码不需要大括号 Runnable r = () -> log.debug("running"); Thread t1 = new Thread(r, "t1"); t1.start(); } }
-
原理之 Thread 与 Runnable 的关系,分析 Thread 的源码,理清它与 Runnable 的关系
-
小结:
- 方法1 是把线程和任务合并在了一起,方法2 是把线程和任务分开了
- 用 Runnable 更容易与线程池等高级 API 配合
- 用 Runnable 让任务类脱离了 Thread 继承体系,更灵活
-
方法三:FutureTask 配合 Thread
public class FutureTask<V> implements RunnableFuture<V>{...} public interface RunnableFuture<V> extends Runnable, Future<V> { /** * Sets this Future to the result of its computation * unless it has been cancelled. */ void run(); } public interface Future<V> { ... V get() throws InterruptedException, ExecutionException; }
FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况,还能抛出异常。
@FunctionalInterface public interface Callable<V> { /** * Computes a result, or throws an exception if unable to do so. * * @return computed result * @throws Exception if unable to compute a result */ V call() throws Exception; }
代码演示:
@Slf4j(topic = "c.Test2") public class Test2 { public static void main(String[] args) throws ExecutionException, InterruptedException { FutureTask<Integer> futureTask = new FutureTask<>(new Callable<Integer>() { @Override public Integer call() throws Exception { log.debug("running"); Thread.sleep(1000); log.debug("running"); return 100; } }); Thread thread = new Thread(futureTask, "t"); thread.start(); // 主线程会一直等待,等待结果的返回,此处会阻塞1秒 Integer num = futureTask.get(); log.debug("获取到的参数为:{}",num); } }
4.2.查看线程
主要是理解
- 交替执行
- 谁先谁后,不由我们控制
- 代码测试:
@Slf4j(topic = "c.TestMultiThread") public class TestMultiThread { public static void main(String[] args) { new Thread(() -> { while(true) { log.debug("running"); } },"t1").start(); new Thread(() -> { while(true) { log.debug("running"); } },"t2").start(); } }
windows
- 任务管理器可以查看进程和线程数,也可以用来杀死进程
tasklist
查看进程
taskkill
杀死进程
linuxps -ef
查看所有进程,可以加管道运算符,ps -ef | grep java
ps -fT -p [PID]
查看某个进程(PID)的所有线程kill
杀死进程
top
按大写 H 切换是否显示线程,以一种动态的方式采用进程信息
top -H -p [PID]
查看某个进程(PID)的所有线程
Java
jps
命令查看所有 Java 进程- jstack [PID] 查看某个 Java 进程(PID)的所有线程状态,抓取快照信息。
- jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)
- jconsole 远程监控配置,或者本地配置
- 需要以如下方式运行你的 java 类
java -Djava.rmi.server.hostname=`ip地址` -Dcom.sun.management.jmxremote - Dcom.sun.management.jmxremote.port=`连接端口` -Dcom.sun.management.jmxremote.ssl=是否安全连接 - Dcom.sun.management.jmxremote.authenticate=是否认证 java类
关闭防火墙:service iptables status
,service iptables stop
- 修改 /etc/hosts 文件将 127.0.0.1 映射至主机名
- jconsole 远程监控配置,或者本地配置
如果要认证访问,还需要做如下步骤
- 复制 jmxremote.password 文件
- 修改 jmxremote.password 和 jmxremote.access 文件的权限为 600 即文件所有者可读写
- 连接时填入 controlRole(用户名),R&D(密码)
4.3.原理之线程运行
-
栈与栈帧
Java Virtual Machine Stacks (Java 虚拟机栈),我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
main栈帧入栈:
method1方法入栈:
method2方法入栈:
-
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。- 线程的 cpu 时间片用完
- 垃圾回收(暂停当前工作的所有线程,由垃圾回收线程回收垃圾)
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。
- 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
- Context Switch 频繁发生会影响性能
4.4.线程API
Thread 类 API:
方法 | 说明 |
---|---|
public void start() | 启动一个新线程,Java虚拟机调用此线程的 run 方法 ,start 方法只是让线程进入就绪,里面代码不一定立刻运行(CPU 的时间片还没分给它)。每个线程对象的start方法只能调用一次,如果调用了多次会出现IllegalThreadStateException |
public void run() | 线程启动后调用该方法,如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。但可以创建 Thread 的子类对象,来覆盖默认行为 |
public void setName(String name) | 给当前线程取名字 |
public void getName() | 获取当前线程的名字 线程存在默认名称:子线程是 Thread-索引,主线程是 main |
public static Thread currentThread() | 获取当前线程对象,代码在哪个线程中执行 |
public static void sleep(long time) | 让当前线程休眠多少毫秒再继续执行 Thread.sleep(0) : 让操作系统立刻重新进行一次 CPU 竞争 |
public static native void yield() | 提示线程调度器让出当前线程对 CPU 的使用,主要是为了测试和调试 |
public final int getPriority() | 返回此线程的优先级 |
public final void setPriority(int priority) | 更改此线程的优先级,常用 1 5 10 |
public void interrupt() | 中断这个线程,异常处理机制。如果被打断线程正在 sleep,wait,join 会导致被打断的线程抛出 InterruptedException,并清除 打断标记 ;如果打断的正在运行的线程,则会设置 打断标记 ;park 的线程被打断,也会设置 打断标记 |
public static boolean interrupted() | 判断当前线程是否被打断,清除打断标记 |
public boolean isInterrupted() | 判断当前线程是否被打断,不清除打断标记 |
public final void join() | 等待这个线程结束 |
public final void join(long millis) | 等待线程运行结束,最多等待 n 毫秒,0 意味着永远等待 |
public final native boolean isAlive() | 线程是否存活(还没有运行完毕) |
public final void setDaemon(boolean on) | 将此线程标记为守护线程或用户线程 |
getState() | 获取线程状态, Java 中线程状态是用 6 个 enum 表示,分别为:NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED |
4.4.1.start与run
直接调用run方法:
@Slf4j(topic = "c.Test4")
public class Test4 {
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("running...");
FileReader.read(Constants.MP4_FULL_PATH);
}
};
t1.run();
// t1.start();
log.debug("do other things...");
}
}
程序仍在 main 线程运行, FileReader.read() 方法调用还是同步的。
调用start:
代码演示线程状态:
@Slf4j(topic = "c.Test5")
public class Test5 {
public static void main(String[] args) {
Thread t1 = new Thread("t1") {
@Override
public void run() {
log.debug("running...");
}
};
System.out.println(t1.getState());
t1.start();
System.out.println(t1.getState());
}
}
- 小结:
- 直接调用 run 是在主线程中执行了 run,没有启动新的线程
- 使用 start 是启动新的线程,通过新的线程间接执行 run 中的代码
4.4.2.sleep与yield
-
sleep
1)调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
2)其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException
3) 睡眠结束后的线程未必会立刻得到执行(需要CPU分配时间片)
4)建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性(具有时间单位)阻塞状态代码演示:
@Slf4j(topic = "c.Test6") public class Test6 { public static void main(String[] args) { Thread t1 = new Thread("t1") { @Override public void run() { try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } }; t1.start(); log.debug("t1 state: {}", t1.getState()); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } log.debug("t1 state: {}", t1.getState()); } }
线程睡眠打断代码演示:@Slf4j(topic = "c.Test7") public class Test7 { public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread("t1") { @Override public void run() { log.debug("enter sleep..."); try { Thread.sleep(2000); } catch (InterruptedException e) { log.debug("wake up..."); e.printStackTrace(); } } }; t1.start(); // sleep静态方法写在哪个线程里就让哪个线程休眠 Thread.sleep(1000); log.debug("interrupt..."); // 主线程唤醒t1线程 t1.interrupt(); } }
TimeUnit 类的 sleep 方法代码演示:@Slf4j(topic = "c.Test8") public class Test8 { public static void main(String[] args) throws InterruptedException { log.debug("enter"); TimeUnit.SECONDS.sleep(1); log.debug("end"); // Thread.sleep(1000); } }
-
yield
1)调用 yield (谦让)会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程。
2)具体的实现依赖于操作系统的任务调度器(谦让可能让不出去,还是分配了时间片) -
二者区别:一个能抢夺 CPU 时间片(就绪状态),一个不能抢夺(阻塞状态);一个带有等待时间,一个没有;
4.4.3.线程优先级
需要看一下源码。
- 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它。
- 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用。
代码演示:
@Slf4j(topic = "c.Test9")
public class Test9 {
public static void main(String[] args) {
Runnable task1 = () -> {
int count = 0;
for (;;) {
System.out.println("---->1 " + count++);
}
};
Runnable task2 = () -> {
int count = 0;
for (;;) {
// Thread.yield();
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();
}
}
4.4.4.sleep小应用-防止CPU占用100%
限制对 CPU 的使用:
在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu,这时可以使用 yield 或 sleep 来让出 cpu 的使用权给其他程序。
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
演示:
加上sleep:
- 可以用 wait 或 条件变量达到类似的效果
- 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
- sleep 适用于无需锁同步的场景
4.4.5.join方法详解
为什么需要 join ?
下面的代码执行,打印 r 是什么?
@Slf4j(topic = "c.Test10")
public class Test10 {
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
log.debug("开始");
Thread t1 = new Thread(() -> {
log.debug("开始");
sleep(1);
log.debug("结束");
r = 10;
},"t1");
t1.start();
// t1.join();
log.debug("结果为:{}", r);
log.debug("结束");
}
}
加上 t1.join();
分析:
- 因为主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能算出
r=10
- 而主线程一开始就要打印 r 的结果,所以只能打印出
r=0
解决方法:
- 用 sleep 行不行?为什么?(不好,因为线程 t1 执行的时间不确定,不知道要睡多久)
- 用 join,加在
t1.start()
之后即可。join 指的是等待线程运行结束,谁来调用就等待谁。
4.4.6.应用之线程同步
以调用方角度来讲,如果
-
需要等待结果返回,才能继续运行就是同步
-
不需要等待结果返回,就能继续运行就是异步
等待多个结果
代码演示:@Slf4j(topic = "c.TestJoin") public class TestJoin { static int r = 0; 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秒 sleep(1); r1 = 10; }); Thread t2 = new Thread(() -> { sleep(2); r2 = 20; }); t1.start(); t2.start(); long start = System.currentTimeMillis(); log.debug("join begin"); // 此处可以互换 t1 和 t2 的join t2.join(); log.debug("t2 join end"); t1.join(); log.debug("t1 join end"); long end = System.currentTimeMillis(); log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start); } }
问,上面代码 cost 大约多少秒?
分析如下:- 第一个 join:等待 t1 时, t2 并没有停止, 而在运行
- 第二个 join:1s 后, 执行到此, t2 也运行了 1s, 因此也只需再等待 1s
如果颠倒两个 join 呢?
有时间的 join... public static void main(String[] args) throws InterruptedException { test3(); } public static void test3() throws InterruptedException { Thread t1 = new Thread(() -> { // 自己封装的sleep函数,表示睡2秒 sleep(2); r1 = 10; }); long start = System.currentTimeMillis(); t1.start(); // 线程执行结束会导致 join 结束 log.debug("join begin"); t1.join(1500); long end = System.currentTimeMillis(); log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start); }
代码改为
t1.join(3000);
表示没等够时间。
4.4.7.interrupt 方法详解
打断 sleep,wait,join 的线程(打断阻塞状态的线程)
这几个方法都会让线程进入阻塞状态,打断 sleep 的线程, 会清空打断状态(false),以 sleep 为例。
代码演示:
@Slf4j(topic = "c.Test11")
public class Test11 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("sleep...");
try {
// sleep,wait, join 在被打断之后,会将打断标记给清空,重置为 false
Thread.sleep(5000);
} catch (InterruptedException e) {
// 抛出被打断的 InterruptedException 异常
e.printStackTrace();
}
},"t1");
t1.start();
Thread.sleep(1000);
log.debug("interrupt");
t1.interrupt();
log.debug("打断标记:{}", t1.isInterrupted());
}
}
打断正常运行的线程
打断正常运行的线程, 不会清空打断状态,线程并不会停下来,可以使用打断标记退出。
代码演示:
@Slf4j(topic = "c.Test12")
public class Test12 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(true) {
// 使用打断标记优雅退出
boolean interrupted = Thread.currentThread().isInterrupted();
if(interrupted) {
log.debug("被打断了, 退出循环");
break;
}
}
}, "t1");
t1.start();
Thread.sleep(1000);
log.debug("interrupt");
t1.interrupt();
}
}
打断park线程
打断 park 线程, 不会清空打断状态
代码演示:
@Slf4j(topic = "c.Test14")
public class Test14 {
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(1);
// t1.interrupt();
}
public static void main(String[] args) throws InterruptedException {
test3();
}
}
打开 t1.interrupt();
和 sleep(1);
如果打断标记已经是 true, 则 park 会失效。
private static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
// 新加入的 park,park 不住
LockSupport.park();
log.debug("unpark...");
}, "t1");
t1.start();
sleep(1);
t1.interrupt();
}
不推荐的方法
还有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁
4.4.8.终止模式之两阶段终止模式(利用 isInterrupt)
终止模式之两阶段终止 (Two Phase Termination ),指的是在一个线程 T1 中如何“优雅”终止线程 T2,这里的【优雅】指的是给 T2 一个料理后事的机会。
- 错误思路
- 使用线程对象的 stop() 方法停止线程
1)stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁 - 使用 System.exit(int) 方法停止线程
2)目的仅是停止一个线程,但这种做法会让整个程序都停止,停止了所有的线程
- 使用线程对象的 stop() 方法停止线程
- 两阶段终止模式
interrupt 可以打断正在执行的线程Thread.interrupted()
,无论这个线程是在sleep
,wait
,还是正常运行(只是正常的会置标记,阻塞的会抛异常并清空标记)
代码演示:
class TPTInterrupt {
private Thread thread;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
if(current.isInterrupted()) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
current.interrupt();
}
// 执行监控操作
}
},"监控线程");
thread.start();
}
public void stop() {
thread.interrupt();
}
}
调用:
结果:
TODO
4.4.9.主线程与守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
演示代码如下:
@Slf4j(topic = "c.Test15")
public class Test15 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
break;
}
}
log.debug("结束");
}, "t1");
// t1.setDaemon(true);
t1.start();
Thread.sleep(1000);
log.debug("结束");
}
}
输出:
注意
- 垃圾回收器线程就是一种守护线程
- Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等
待它们处理完当前请求
4.5.线程状态
4.5.1.五种状态
这是从 操作系统 层面来描述的。
- 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
- 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
- 【运行状态】指获取了 CPU 时间片运行中的状态
1)当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换 - 【阻塞状态】
1)如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
2)等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
3)与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
4)【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态
4.5.2.六种状态
这是从 Java API 层面来描述的。
根据 Thread.State 枚举,分为六种状态。
NEW
线程刚被创建,但是还没有调用start()
方法RUNNABLE
当调用了start()
方法之后,注意,Java API 层面的RUNNABLE
状态涵盖了 操作系统 层面的【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)BLOCKED
,WAITING
,TIMED_WAITING
都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述TERMINATED
当线程代码运行结束
4.6.华罗庚统筹方法
TODO
4.7.本章小结
本章的重点在于掌握
- 线程创建
- 线程重要 api,如 start,run,sleep,join,interrupt 等
- 线程状态
- 应用方面
1)异步调用:主线程执行期间,其它线程异步执行耗时操作
2)提高效率:并行计算,缩短运算时间
3)同步等待:join
4)统筹规划:合理使用线程,得到最优效果 - 原理方面
1)线程运行流程:栈、栈帧、上下文切换、程序计数器
2)Thread 两种创建方式 的源码 - 模式方面
1)终止模式之两阶段终止
5.并发之共享模型
5.1.共享模型之管程(悲观锁)
5.1.1.共享带来的问题
小故事
- 老王(操作系统)有一个功能强大的算盘(CPU),现在想把它租出去,赚一点外快
- 小南、小女(线程)来使用这个算盘来进行一些计算,并按照时间给老王支付费用
- 但小南不能一天24小时使用算盘,他经常要小憩一会(sleep),又或是去吃饭上厕所(阻塞 io 操作),有时还需要一根烟,没烟时思路全无(wait)这些情况统称为(阻塞)
- 在这些时候,算盘没利用起来(不能收钱了),老王觉得有点不划算
- 另外,小女也想用用算盘,如果总是小南占着算盘,让小女觉得不公平
- 于是,老王灵机一动,想了个办法 [ 让他们每人用一会,轮流使用算盘 ]
- 这样,当小南阻塞的时候,算盘可以分给小女使用,不会浪费,反之亦然
- 最近执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了一个笔记本(主存),把一些中间结果先记在本上
- 计算流程是这样的
- 但是由于分时系统,有一天还是发生了事故
- 小南刚读取了初始值 0 做了个 +1 运算,还没来得及写回结果
- 老王说 [ 小南,你的时间到了,该别人了,记住结果走吧 ],于是小南念叨着 [ 结果是1,结果是1…] 不甘心地到一边待着去了(上下文切换)
- 老王说 [ 小女,该你了 ],小女看到了笔记本上还写着 0 做了一个 -1 运算,将结果 -1 写入笔记本
- 这时小女的时间也用完了,老王又叫醒了小南:[小南,把你上次的题目算完吧],小南将他脑海中的结果 1 写入了笔记本
- 小南和小女都觉得自己没做错,但笔记本里的结果是 1 而不是 0
Java体现
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
代码演示:
@Slf4j(topic = "c.Test17")
public class Test17 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", room.getCounter());
}
}
class Room {
private int counter = 0;
/**
* 不加 synchronized
*/
public void increment() {
counter++;
}
public void decrement() {
counter--;
}
public int getCounter() {
return counter;
}
}
问题分析
以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析。
例如对于 i++
而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
而对应 i--
也是类似:
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
- 单线程代码之间没有指令的交错,不出现线程安全
- 多线程代码之间会指令的交错,线程不安全
- 出现负数的情况:
- 出现正数的情况
- 出现负数的情况:
临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
1)多个线程读共享资源其实也没有问题
2)在多个线程对共享资源读写操作时发生指令交错,就会出现问题 - 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如,下面代码中的临界区
竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测(多个指令交错运行),称之为发生了竞态条件
5.1.2.synchronized解决方案
应用之互斥
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:
synchronized
,Lock
- 非阻塞式的解决方案:原子变量
本次课使用阻塞式的解决方案:synchronized
,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
synchronized
语法
解决
@Slf4j(topic = "c.Test17")
public class Test17 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
// 对象加锁
synchronized (room){
room.increment();
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
// 同一对象加锁
synchronized (room){
room.decrement();
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", room.getCounter());
}
}
class Room {
private int counter = 0;
/**
* 方法上不加 synchronized
*/
public void increment() {
counter++;
}
public void decrement() {
counter--;
}
public int getCounter() {
return counter;
}
}
你可以做这样的类比:
synchronized(对象)
中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人- 当线程 t1 执行到
synchronized(room)
时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++
代码 - 这时候如果 t2 也运行到了
synchronized(room)
时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了 - 这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入
- 当 t1 执行完
synchronized{}
块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count-- 代码
注意:
- CPU 时间片用完了,门还是锁住的,当前线程A被踢出去,其他的线程还是进不来,还是处于阻塞状态,直到下一次分配时间片到A线程,因为有锁的钥匙,还是能执行,直到执行完,并唤醒其他等待的线程
- 当线程获取锁,并锁住了门,其它的线程尝试获取锁,获取失败之后,就会陷入阻塞状态
思考
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
为了加深理解,请思考下面的问题
- 如果把
synchronized(obj)
放在 for 循环的外面,如何理解?整个for循环是原子性,两万行指令是原子的。 - 如果 t1
synchronized(obj1)
而 t2synchronized(obj2)
会怎样运作?锁对象,加的不是同一把锁。 - 如果 t1
synchronized(obj)
而 t2 没有加会怎么样?如何理解?锁对象,也是不行的,大家执行的逻辑要一样,先获取锁,再执行临界区代码。
面向对象改进
class Room {
int value = 0;
public void increment() {
// 锁住当前的room对象
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count: {}" , room.get());
}
}
5.1.3.方法上的synchronized
synchronized
加载成员方法上。(synchronized 只能锁对象)
synchronized
加载静态方法上。(锁住类对象)
不加 synchronized 的方法。
不加 synchronzied 的方法就好比不遵守规则的人,不去老实排队(好比翻窗户进去的)
所谓的”线程八锁”
其实就是考察 synchronized 锁住的是哪个对象。
-
情况1:12 或 21
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public synchronized void a() { log.debug("1"); } public synchronized void b() { log.debug("2"); } }
-
情况2:方法a加上
sleep(1);
1s后12,或 2=>1s后 1@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public synchronized void a() { sleep(1); log.debug("1"); } public synchronized void b() { log.debug("2"); } }
-
情况3:3 =>1s =>12 或 23 =>1s =>1 或 32 => 1s =>1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.b(); }).start(); new Thread(()->{ log.debug("begin"); // 没有加锁 n1.c(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public synchronized void a() { sleep(1); log.debug("1"); } public synchronized void b() { log.debug("2"); } public void c() { log.debug("3"); } }
-
情况4:2 =>1s 后 1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); Number n2 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n2,没有互斥 n2.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public synchronized void a() { sleep(1); log.debug("1"); } public synchronized void b() { log.debug("2"); } }
-
情况5:2 1s 后 1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n1 n1.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public static synchronized void a() { sleep(1); log.debug("1"); } public synchronized void b() { log.debug("2"); } }
-
情况6:1s 后12, 或 2 1s后 1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n1.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public static synchronized void a() { sleep(1); log.debug("1"); } public static synchronized void b() { log.debug("2"); } }
-
情况7:2 1s 后 1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); Number n2 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 n2 n2.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public static synchronized void a() { sleep(1); log.debug("1"); } public synchronized void b() { log.debug("2"); } }
-
情况8:1s 后12, 或 2 1s后 1
@Slf4j(topic = "c.Test8Locks") public class Test8Locks { public static void main(String[] args) { Number n1 = new Number(); Number n2 = new Number(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n1.a(); }).start(); new Thread(() -> { log.debug("begin"); // 锁的是 类对象 n2.b(); }).start(); } } @Slf4j(topic = "c.Number") class Number{ public static synchronized void a() { sleep(1); log.debug("1"); } public static synchronized void b() { log.debug("2"); } }
5.1.4.线程安全分析
成员变量和静态变量是否线程安全?(成员变量只有一份,单例模式,存在共享)
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
1)如果只有读操作,则线程安全
2)如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是否线程安全?
- 局部变量(基本数据类型)是线程安全的(该变量每个栈帧独一份,不共享)
- 但局部变量引用的对象则未必
1)如果该对象没有逃离方法的作用访问,它是线程安全的
2)如果该对象逃离方法的作用范围,需要考虑线程安全
局部变量线程安全分析
每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享
局部变量的引用稍有不同。
先看一个成员变量的例子
public class TestThreadSafe {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe test = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// 临界区
method2();
method3();
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
}
其中一种情况是,如果线程2 还未 add,线程1 remove 就会报错:
分析:
- 无论哪个线程中的 method2 引用的都是同一个对象中的 list 成员变量
- method3 与 method2 分析相同
将 list 修改为局部变量
public class TestThreadSafe {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafe test = new ThreadSafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + (i+1)).start();
}
}
}
class ThreadSafe {
public final void method1(int loopNumber) {
// 局部变量
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
System.out.println(1);
list.remove(0);
}
}
那么就不会有上述问题了。
- list 是局部变量(没有暴露给外部),每个线程调用时会创建其不同实例,没有共享
- 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
- method3 的参数分析与 method2 相同
方法访问修饰符带来的思考,如果把 method2 和 method3 的方法修改为 public 会不会代理线程安全问题?
-
情况1:有其它线程调用 method2 和 method3(如果两个线程分别调用,传入的不是同一对象,没有线程安全问题)
-
情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法,即(局部变量的引用暴露给其他的线程,所以方法的访问修饰符是有意义的,能够在一定的程度上保证线程安全)
class ThreadSafe { public final void method1(int loopNumber) { // 局部变量 ArrayList<String> list = new ArrayList<>(); for (int i = 0; i < loopNumber; i++) { method2(list); method3(list); } } public void method2(ArrayList<String> list) { list.add("1"); } public void method3(ArrayList<String> list) { System.out.println(1); list.remove(0); } } class ThreadSafeSubClass extends ThreadSafe{ @Override public void method3(ArrayList<String> list) { System.out.println(2); new Thread(() -> { list.remove(0); }).start(); } }
从这个例子可以看出 private 或 final 提供【安全】的意义所在,请体会开闭原则中的【闭】
常见的线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为: public synchronized V put(K key, V value) {...}
- 它们的每个方法是原子的
- 但注意它们多个方法的组合不是原子的,见后面分析
线程安全类方法的组合
分析下面代码是否线程安全?public synchronized V get(Object key) {...}
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变(只能读不能改),因此它们的方法都是线程安全的。
有同学或许有疑问,String 有 replace,substring 等方法【可以】改变值啊,那么这些方法又是如何保证线程安全的呢?
实例分析
例1:
例2:
例3:
例4:
例5:
例6:
例7:
其中 foo 的行为是不确定的,可能导致不安全的发生,被称之为外星方法(将方法设计成final和private,防止子类覆盖父类的方法,导致线程安全问题,请比较 JDK 中 String 类的实现,public final class String ...
)
5.1.5.习题巩固
卖票练习
测试下面代码是否存在线程安全问题,并尝试改正。
@Slf4j(topic = "c.ExerciseSell")
public class ExerciseSell {
public static void main(String[] args) throws InterruptedException {
// 模拟多人买票
TicketWindow window = new TicketWindow(1000);
// 所有线程的集合
List<Thread> threadList = new ArrayList<>();
// 卖出的票数统计
List<Integer> amountList = new Vector<>(); // Vector 的 add 加了syn,线程安全
// 不能用ArrayList
// List<Integer> sellCount = new ArrayList<>();
for (int i = 0; i < 4000; i++) {
Thread thread = new Thread(() -> {
/**
* 以下是两个共享变量,不存在组合问题,组合线程安全不存在
*/
// 买票
// 共享变量,存在临界区,sell方法有读有写
int amount = window.sell(random(5));
// 统计买票数
// 共享变量,存在临界区,有写
amountList.add(amount);
});
// threadList 也是共享变量,但是只会被主线程使用,不存在线程安全问题
threadList.add(thread);
thread.start();
}
// 主线程等待所有的卖票线程结束
for (Thread thread : threadList) {
thread.join();
}
// 统计卖出的票数和剩余票数
log.debug("余票:{}",window.getCount());
log.debug("卖出的票数:{}", amountList.stream().mapToInt(i-> i).sum());
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~5
public static int random(int amount) {
return random.nextInt(amount) + 1;
}
}
// 售票窗口
class TicketWindow {
private int count;
public TicketWindow(int count) {
this.count = count;
}
// 获取余票数量
public int getCount() {
return count;
}
// 售票, 加入 synchronized 就可以保障线程安全
public int sell(int amount) {
if (this.count >= amount) {
this.count -= amount;
return amount;
} else {
return 0;
}
}
}
测试脚本
for /L %n in (1,1,10) do java -cp ".;C:\Users\manyh\.m2\repository\ch\qos\logback\logbackclassic\1.2.3\logback-classic-1.2.3.jar;C:\Users\manyh\.m2\repository\ch\qos\logback\logbackcore\1.2.3\logback-core-1.2.3.jar;C:\Users\manyh\.m2\repository\org\slf4j\slf4japi\1.7.25\slf4j-api-1.7.25.jar" cn.itcast.n4.exercise.ExerciseSell
转账练习
测试下面代码是否存在线程安全问题,并尝试改正。
@Slf4j(topic = "c.ExerciseTransfer")
public class ExerciseTransfer {
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1000);
Account b = new Account(1000);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
a.transfer(b, randomAmount());
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
b.transfer(a, randomAmount());
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
// 查看转账2000次后的总金额
log.debug("total:{}", (a.getMoney() + b.getMoney()));
}
// Random 为线程安全
static Random random = new Random();
// 随机 1~100
public static int randomAmount() {
return random.nextInt(100) + 1;
}
}
// 账户
class Account {
private int money;
public Account(int money) {
this.money = money;
}
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
// 转账
public void transfer(Account target, int amount) {
synchronized(Account.class) {
// 账户 a 的money 和账户
if (this.money >= amount) {
this.setMoney(this.getMoney() - amount);
target.setMoney(target.getMoney() + amount);
}
}
}
}
5.1.6.Monitor概念
Java对象头
以 32 位虚拟机为例:
1)普通对象(Integer:8+4=12 字节)
- 其中 Mark Word 结构为
2)数组对象
注意:64 位虚拟机 Mark Word
Monitor
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor
对象,如果使用 synchronized
给对象上锁(重量级)之后,该对象头的 Mark Word
中就被设置指向 Monitor
对象的指针。
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会(先检查obj是否关联了一个Monitor和其中的Owner)将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),(先通过MarkWord检查obj,是否关联了一个Monitor和其中的Owner,发现关联了一个Monitor并且具有Owner Thread-2)就会进入EntryList BLOCKED(也是一种关联,进入阻塞队列或者叫等待队列)
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析(如下是Monitor 结构)
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果(一个对象关联一个monitor)
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
- synchronized 的原理是让每个对象都关联一个 monitor,但是其由操作系统提供,使用成本较高
- Java6 对 synchronized 进行了改进,可以使用轻量级锁,或者偏向锁优化
原理之synchronized
对应的字节码为
小故事引入轻量级锁和偏向锁
故事角色(此处例子举得不太好)
- 老王 - JVM
- 小南 - 线程
- 小女 - 线程
- 房间 - 对象
- 房间门上 - 防盗锁 - Monitor
- 房间门上 - 小南书包 - 轻量级锁
- 房间门上 - 写上小南大名 - 偏向锁
- 批量重刻名 - 一个类的偏向锁撤销到达 20 阈值
- 不能刻名字 - 批量撤销该类对象的偏向锁,设置该类不可偏向
小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁,当上下文切换时,锁住门。这样,即使他离开了,别人也进不了门,他的工作就是安全的。
但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的(竞争错开),小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?
(轻量级锁)小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进门前得翻翻书包,看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁门的方式(竞争出现)。
后来,小女回老家了,很长一段时间都不会用这个房间。小南每次还是挂书包,翻书包,虽然比锁门省事了,但仍然觉得麻烦。
(偏向级锁,就是某个房间偏向于某个线程,只有一个线程用)于是,小南干脆在门上刻上了自己的名字:【小南专属房间,其它人勿用】,下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式。
同学们都放假回老家了,小南就膨胀了,在 20 个房间刻上了自己的名字,想进哪个进哪个。后来他自己放假回老家了,这时小女回来了(她也要用这些房间),结果就是得一个个地擦掉小南刻的名字,升级为挂书包的方式。老王觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门上刻上自己的名字。
后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包(因为锁撤销的情况常见,不可偏向)。
原理之 synchronized 进阶
-
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是
synchronized
假设有两个方法同步块,利用同一个对象加锁
-
1)创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
-
2)让锁记录中 Object reference 指向锁对象,并尝试用 cas 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
-
3)如果 cas 替换成功(标记对象中的状态为 01),对象头中存储了
锁记录地址和状态 00
,表示由该线程给对象加锁,这时图示如下
-
4)如果 cas 失败,有两种情况
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入
锁膨胀
过程 - 如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数(此时再执行CAS会失败,发现是自己加的,发生锁重入)
- 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入
-
5)当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
6)当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
-
-
锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
- 1)当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 2)这时 Thread-1 加轻量级锁失败,进入
锁膨胀
流程(轻量级锁没有阻塞的概念)
- 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
- 3)当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,
失败
。这时会进入重量级解锁流程
,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
- 1)当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
-
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞,因为阻塞要发生上下文切换,比较耗性能(就是不会立即进入 EntryList,适用于多核CPU的某个空转)。自旋重试成功的情况
自旋重试失败的情况
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能
-
偏向锁
轻量级锁(不需要 monitor,而是利用栈帧中的锁记录来充当)在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作(对象头的MarkWord与所记录的地址进行替换,会CAS失败,但是仍然会将锁记录保留,可以刻线程的名字,也就是偏向锁,只发生一次 CAS 操作)。Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有。(一个线程使用该对象,偏向锁)
例如:
偏向状态
回忆一下对象头格式
一个对象创建时:- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
- XX:BiasedLockingStartupDelay=0
来禁用延迟 - 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、age 都为 0,第一次用到 hashcode 时才会赋值
-
1)测试延迟特性
-
2)测试偏向锁
注意
处于偏向锁的对象解锁后,线程 id 仍存储于对象头中 -
3)测试禁用
在上面测试代码运行时在添加 VM 参数-XX:-UseBiasedLocking
禁用偏向锁
-
4)测试 hashCode
正常状态对象一开始是没有 hashCode 的,第一次调用才生成。(当一个可偏向的对象,调用了它的HashCode方法之后,会撤销该对象的偏向状态,因为偏向锁对象头处没地方存储哈希码,轻量级锁存储在锁记录中,重量级锁存储在monitor中)
-
撤销偏向锁 - 调用对象 hashCode
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
在调用 hashCode 后使用偏向锁,记得去掉 -XX:-UseBiasedLocking
总结:偏向锁和hashcode是互斥存在的;轻量级锁的hashcode存储在线程栈帧的锁记录中;重量级锁的hashcode存储在Monitor对象中!
撤销偏向锁 - 其它线程使用对象
当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。
撤销偏向锁 - 调用 wait/notify(这两个只有重量级才有)
批量重偏向
如果对象虽然被多个线程访问,但没有竞争(一个线程使用完了,另外一个线程错开使用),这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
输出:
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
参考资料
https://github.com/farmerjohngit/myblog/issues/12
https://www.cnblogs.com/LemonFive/p/11246086.html
https://www.cnblogs.com/LemonFive/p/11248248.html
偏向锁论文
锁消除
锁消除
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations=3)
@Measurement(iterations=5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class MyBenchmark {
static int x = 0;
@Benchmark
// 不加锁
public void a() throws Exception {
x++;
}
@Benchmark
// JIT 即时编译器
public void b() throws Exception {
Object o = new Object();
// 加锁
synchronized (o) {
x++;
}
}
}
java -jar benchmarks.jar
关掉 JIT 的优化,java -XX:-EliminateLocks -jar benchmarks.jar
,就是不想用锁消除。
锁粗化:
对相同对象多次加锁,导致线程发生多次重入,可以使用锁粗化方式来优化,这不同于之前讲的细分锁的粒度。
5.1.7.wait notify
小故事 - 为什么需要 wait
- 由于条件不满足,小南不能继续进行计算
- 但小南如果一直占用着锁,其它人就得一直阻塞,效率太低
- 于是老王单开了一间休息室(调用 wait 方法),让小南到休息室(WaitSet)等着去了,但这时锁释放开,其它人可以由老王随机安排进屋
- 直到小M将烟送来,大叫一声 [ 你的烟到了 ] (另外一个线程获得了锁,调用 notify 方法)
- 小南于是可以离开休息室,重新进入竞争锁的队列
原理之 wait / notify
wait notify 原理
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争
API 介绍
obj.wait()
让进入 object 监视器的线程到 waitSet 等待obj.notify()
在 object 上正在 waitSet 等待的线程中挑一个唤醒obj.notifyAll()
让 object 上正在 waitSet 等待的线程全部唤醒
它们都是线程之间进行协作的手段,都属于 Object 对象的方法。必须获得此对象的锁(成为了Owner以后),才能调用这几个方法。不能直接调用 lock.wait();
@Slf4j(topic = "c.Test18")
public class Test18 {
static final Object lock = new Object();
public static void main(String[] args) {
synchronized (lock) {
try {
// 先获取锁才能执行 wait()
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
notify() 和 notifyAll() 的区别
@Slf4j(topic = "c.TestWaitNotify")
public class TestWaitNotify {
final static Object obj = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait(); // 让线程在obj关联的monitor中的WaitSet中上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t1").start();
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait(); // 让线程在obj关联的monitor中的WaitSet中上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t2").start();
// 主线程两秒后执行
sleep(0.5);
log.debug("唤醒 obj 上其它线程");
synchronized (obj) {
// obj.notify(); // 唤醒obj关联的monitor中的WaitSet中上一个线程
obj.notifyAll(); // 唤醒obj关联的monitor中的WaitSet中上所有等待线程
}
}
}
notify 的一种结果
notifyAll 的结果
wait()
方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到notify 为止
wait(long n)
有时限的等待, 到 n 毫秒后结束等待,获取时间片后继续向下运行,或是被 notify
5.1.8.wait notify的正确姿势
开始之前先看看
sleep(long n)
和 wait(long n)
的区别
1)sleep 是 Thread 方法,而 wait 是 Object 的方法,所有的对象都有
2)sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized
一起用(要获取锁)
3)sleep 在睡眠的同时,不会释放对象锁的,但 wait 在等待的时候会释放对象锁
4)共同点:它们状态 TIMED_WAITING
,都会放弃 CPU 的使用权
代码演示
@Slf4j(topic = "c.Test19")
public class Test19 {
// 建立将锁对象改成 final 导致引用不可变,同一个对象
static final Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
log.debug("获得锁");
try {
Thread.sleep(20000);
// lock.wait(20000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
Sleeper.sleep(1);
synchronized (lock) {
log.debug("获得锁");
}
}
}
使用 lock.wait(20000);
-
step 1
思考下面的解决方案好不好,为什么?@Slf4j(topic = "c.TestCorrectPosture") public class TestCorrectPostureStep1 { static final Object room = new Object(); // 共享变量 static boolean hasCigarette = false; // 有没有烟 static boolean hasTakeout = false; public static void main(String[] args) { new Thread(() -> { synchronized (room) { log.debug("有烟没?[{}]", hasCigarette); if (!hasCigarette) { log.debug("没烟,先歇会!"); sleep(2); // 进入EntryList,不会释放锁 } log.debug("有烟没?[{}]", hasCigarette); if (hasCigarette) { log.debug("可以开始干活了"); } } }, "小南").start(); for (int i = 0; i < 5; i++) { new Thread(() -> { synchronized (room) { log.debug("可以开始干活了"); } }, ("其它人"+i)).start(); } // 一秒后送烟 sleep(1); new Thread(() -> { // 这里能不能加 synchronized (room)?不能加,否则送烟的也获得不了锁 // synchronized (room) { hasCigarette = true; log.debug("烟到了噢!"); // } }, "送烟的").start(); } }
加上synchronized (room)
- 其它干活的线程,都要一直阻塞,效率太低
- 小南线程必须睡足 2s 后才能醒来,就算烟提前送到,也无法立刻醒来
- 加了 synchronized (room) 后,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main 没加synchronized 就好像 main 线程是翻窗户进来的
- 解决方法,使用 wait - notify 机制
-
step 2
思考下面的实现行吗,为什么?@Slf4j(topic = "c.TestCorrectPosture") public class TestCorrectPostureStep2 { static final Object room = new Object(); static boolean hasCigarette = false; static boolean hasTakeout = false; public static void main(String[] args) { new Thread(() -> { synchronized (room) { log.debug("有烟没?[{}]", hasCigarette); if (!hasCigarette) { log.debug("没烟,先歇会!"); try { room.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("有烟没?[{}]", hasCigarette); if (hasCigarette) { log.debug("可以开始干活了"); } } }, "小南").start(); for (int i = 0; i < 5; i++) { new Thread(() -> { synchronized (room) { log.debug("可以开始干活了"); } }, "其它人").start(); } sleep(1); new Thread(() -> { synchronized (room) { hasCigarette = true; log.debug("烟到了噢!"); room.notify(); } }, "送烟的").start(); } }
- 解决了其它干活的线程阻塞的问题
- 但如果有其它线程也在等待条件呢?(送烟线程会不会错误的叫醒其他的线程,存在虚假唤醒)
-
step 3
@Slf4j(topic = "c.TestCorrectPosture") public class TestCorrectPostureStep3 { static final Object room = new Object(); static boolean hasCigarette = false; static boolean hasTakeout = false; // 虚假唤醒 public static void main(String[] args) { new Thread(() -> { synchronized (room) { log.debug("有烟没?[{}]", hasCigarette); if (!hasCigarette) { log.debug("没烟,先歇会!"); try { room.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("有烟没?[{}]", hasCigarette); if (hasCigarette) { log.debug("可以开始干活了"); } else { log.debug("没干成活..."); } } }, "小南").start(); new Thread(() -> { synchronized (room) { Thread thread = Thread.currentThread(); log.debug("外卖送到没?[{}]", hasTakeout); if (!hasTakeout) { log.debug("没外卖,先歇会!"); try { room.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("外卖送到没?[{}]", hasTakeout); if (hasTakeout) { log.debug("可以开始干活了"); } else { log.debug("没干成活..."); } } }, "小女").start(); sleep(1); new Thread(() -> { synchronized (room) { hasTakeout = true; log.debug("外卖到了噢!"); room.notify(); } }, "送外卖的").start(); } }
- notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程,称之为【虚假唤醒】
- 解决方法,改为
notifyAll()
- 用 notifyAll 仅解决某个线程的唤醒问题,但使用 if + wait 判断仅有一次机会,一旦条件不成立,就没有重新判断的机会了
- 解决方法,用 while + wait,当条件不成立,再次 wait
-
step 4
@Slf4j(topic = "c.TestCorrectPosture") public class TestCorrectPostureStep5 { static final Object room = new Object(); static boolean hasCigarette = false; static boolean hasTakeout = false; public static void main(String[] args) { new Thread(() -> { synchronized (room) { log.debug("有烟没?[{}]", hasCigarette); while (!hasCigarette) { log.debug("没烟,先歇会!"); try { room.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("有烟没?[{}]", hasCigarette); if (hasCigarette) { log.debug("可以开始干活了"); } else { log.debug("没干成活..."); } } }, "小南").start(); new Thread(() -> { synchronized (room) { Thread thread = Thread.currentThread(); log.debug("外卖送到没?[{}]", hasTakeout); while (!hasTakeout) { log.debug("没外卖,先歇会!"); try { room.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("外卖送到没?[{}]", hasTakeout); if (hasTakeout) { log.debug("可以开始干活了"); } else { log.debug("没干成活..."); } } }, "小女").start(); sleep(1); new Thread(() -> { synchronized (room) { hasTakeout = true; log.debug("外卖到了噢!"); room.notifyAll(); } }, "送外卖的").start(); } }
总结(正确姿势):
5.1.9.同步模式之保护性暂停
1)定义
即 Guarded Suspension,用在一个线程等待另一个线程的执行结果。(join 是阻塞,这是消息通知)
要点
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
- JDK 中,join 的实现、Future 的实现,采用的就是此模式
- 因为要等待另一方的结果,因此归类到同步模式
2)实现
class GuardedObject {
private Object response;
private final Object lock = new Object();
// 获取结果
public Object get() {
synchronized (lock) {
// 条件不满足则等待, while 解决虚假唤醒
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();
}
}
}
应用:一个线程等待另一个线程的执行结果
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
try {
// 子线程执行下载
List<String> response = download();
log.debug("download complete...");
guardedObject.complete(response);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
log.debug("waiting...");
// 主线程阻塞等待
Object response = guardedObject.get();
log.debug("get response: [{}] lines", ((List<String>) response).size());
}
执行结果:
3)带超时版 GuardedObject
如果要控制超时时间呢?
class GuardedObjectV2 {
// 结果
private Object response;
private final Object lock = new Object();
public Object get(long millis) {
synchronized (lock) {
// 1) 记录最初时间
long begin = System.currentTimeMillis();
// 2) 已经经历的时间,用作退出循环判断
long timePassed = 0;
while (response == null) {
// 4) 假设 millis 是 1000,结果在 400 时唤醒了,那么还有 600 要等
// 经历的时间超过了最大等待时间,则退出
long waitTime = millis - timePassed;
log.debug("waitTime: {}", waitTime);
if (waitTime <= 0) {
log.debug("break...");
break;
}
try {
lock.wait(waitTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3) 如果提前被唤醒,这时已经经历的时间假设为 400
// 求得经历时间
timePassed = System.currentTimeMillis() - begin;
log.debug("timePassed: {}, object is null {}",
timePassed, response == null);
}
return response;
}
}
public void complete(Object response) {
synchronized (lock) {
// 条件满足,通知等待线程
this.response = response;
log.debug("notify...");
lock.notifyAll();
}
}
}
测试,没有超时。
public static void main(String[] args) {
GuardedObjectV2 v2 = new GuardedObjectV2();
new Thread(() -> {
sleep(1);
v2.complete(null);
sleep(1);
v2.complete(Arrays.asList("a", "b", "c"));
}).start();
Object response = v2.get(2500);
if (response != null) {
log.debug("get response: [{}] lines", ((List<String>) response).size());
} else {
log.debug("can't get response");
}
}
输出:
测试,超时
输出:
原理之 join(利用保护性暂停)
public final synchronized void join(long millis) throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0L;
if (millis < 0L) {
throw new IllegalArgumentException("timeout value is negative");
} else {
if (millis == 0L) {
while(this.isAlive()) {
this.wait(0L);
}
} else {
while(this.isAlive()) {
long delay = millis - now;
if (delay <= 0L) {
break;
}
this.wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
}
是调用者轮询检查线程 alive 状态,t1.join();
等价于下面的代码
4)多任务版 GuardedObject
图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员
如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理
新增 id 用来标识 Guarded Object
class GuardedObject {
// 标识 Guarded Object
private int id;
public GuardedObject(int id) {
this.id = id;
}
public int getId() {
return id;
}
// 结果
private Object response;
// 获取结果
// timeout 表示要等待多久 2000
public Object get(long timeout) {
synchronized (this) {
// 开始时间 15:00:00
long begin = System.currentTimeMillis();
// 经历的时间
long passedTime = 0;
while (response == null) {
// 这一轮循环应该等待的时间
long waitTime = timeout - passedTime;
// 经历的时间超过了最大等待时间时,退出循环
if (timeout - passedTime <= 0) {
break;
}
try {
this.wait(waitTime); // 虚假唤醒 15:00:01
} catch (InterruptedException e) {
e.printStackTrace();
}
// 求得经历时间
passedTime = System.currentTimeMillis() - begin; // 15:00:02 1s
}
return response;
}
}
// 产生结果
public void complete(Object response) {
synchronized (this) {
// 给结果成员变量赋值
this.response = response;
this.notifyAll();
}
}
}
中间解耦类
class Mailboxes {
private static Map<Integer, GuardedObject> boxes = new Hashtable<>();
private static int id = 1;
// 产生唯一 id
private static synchronized int generateId() {
return id++;
}
public static GuardedObject getGuardedObject(int id) {
// 获取键值,并做删除
return boxes.remove(id);
}
public static GuardedObject createGuardedObject() {
GuardedObject go = new GuardedObject(generateId());
boxes.put(go.getId(), go);
return go;
}
public static Set<Integer> getIds() {
return boxes.keySet();
}
}
业务相关类
@Slf4j(topic = "c.People")
class People extends Thread{
@Override
public void run() {
// 收信
GuardedObject guardedObject = Mailboxes.createGuardedObject();
log.debug("开始收信 id:{}", guardedObject.getId());
Object mail = guardedObject.get(5000);
log.debug("收到信 id:{}, 内容:{}", guardedObject.getId(), mail);
}
}
@Slf4j(topic = "c.Postman")
class Postman extends Thread {
private int id;
private String mail;
public Postman(int id, String mail) {
this.id = id;
this.mail = mail;
}
@Override
public void run() {
GuardedObject guardedObject = Mailboxes.getGuardedObject(id);
log.debug("送信 id:{}, 内容:{}", id, mail);
guardedObject.complete(mail);
}
}
测试
@Slf4j(topic = "c.Test20")
public class Test20 {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 3; i++) {
new People().start();
}
Sleeper.sleep(1);
for (Integer id : Mailboxes.getIds()) {
new Postman(id, "内容" + id).start();
}
}
}
某次运行结果
5.1.10.异步模式之生产者/消费者模式
1)定义
要点
- 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
- 消费队列可以用来平衡生产和消费的线程资源(生产者和先飞这的数量)
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据,消息也不会被立即消费(异步)
- JDK 中各种阻塞队列,采用的就是这种模式
2)实现
@Slf4j(topic = "c.Test21")
public class Test21 {
public static void main(String[] args) {
MessageQueue queue = new MessageQueue(2);
for (int i = 0; i < 3; i++) {
int id = i;
// lambda中的变量要是final的,防止修改,i可修改,int id 每次创建新的。
new Thread(() -> {
queue.put(new Message(id , "值"+id));
}, "生产者" + i).start();
}
new Thread(() -> {
while(true) {
sleep(1);
Message message = queue.take();
}
}, "消费者").start();
}
}
// 消息队列类,java 线程之间通信,非 RabbitMQ中的,进程间消费
@Slf4j(topic = "c.MessageQueue")
class MessageQueue {
// 消息的队列集合
private LinkedList<Message> list = new LinkedList<>();
// 队列容量
private int capcity;
public MessageQueue(int capcity) {
this.capcity = capcity;
}
// 获取消息
public Message take() {
// 检查队列是否为空
synchronized (list) {
while(list.isEmpty()) {
try {
log.debug("队列为空, 消费者线程等待");
// 进入某个对象的 waitSet 中进行等待(锁住某个对象)
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 从队列头部获取消息并返回
Message message = list.removeFirst();
log.debug("已消费消息 {}", message);
// 将等待的生产者唤醒
list.notifyAll();
return message;
}
}
// 存入消息
public void put(Message message) {
synchronized (list) {
// 检查对象是否已满
while(list.size() == capcity) {
try {
log.debug("队列已满, 生产者线程等待");
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 将消息加入队列尾部
list.addLast(message);
log.debug("已生产消息 {}", message);
// 将等待消息的消费者唤醒
list.notifyAll();
}
}
}
final class Message {
private int id;
private Object value;
public Message(int id, Object value) {
this.id = id;
this.value = value;
}
// 没有 setter 方法,保证不可变,线程安全
public int getId() {
return id;
}
public Object getValue() {
return value;
}
@Override
public String toString() {
return "Message{" +
"id=" + id +
", value=" + value +
'}';
}
}
某次运行结果
5.1.11.Park & Unpark
基本使用
它们是 LockSupport 类中的方法(park 对应的是 wait 状态,无时限的等待)
先 park 再 unpark。
@Slf4j(topic = "c.TestParkUnpark")
public class TestParkUnpark {
public static void main(String[] args) {
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
@Slf4j(topic = "c.TestParkUnpark")
public class TestParkUnpark {
public static void main(String[] args) {
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 必须配合 Object Monitor 一起使用,而 park,unpark 不必
- park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
- park & unpark 可以先 unpark,而 wait & notify 不能先 notify
原理之 park & unpark
每个线程都有自己的一个 Parker
对象,由三部分组成 _counter
, _cond
和 _mutex
打个比喻
-
线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
-
调用 park 就是要看需不需要停下来歇息
- 1)如果备用干粮耗尽,那么钻进帐篷歇息
- 2)如果备用干粮充足,那么不需停留,继续前进
-
调用 unpark,就好比令干粮充足 counter + 1
-
1)如果这时线程还在帐篷,就唤醒让他继续前进
-
2)如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进,因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮
直接调用 park 的场景
1)当前线程调用 Unsafe.park() 方法
2)检查 _counter ,本情况为 0,这时,获得 _mutex 互斥锁
3)线程进入 _cond 条件变量阻塞
4)设置 _counter = 0
先调用 park 再调用 unpark
1) 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2)唤醒 _cond 条件变量中的 Thread_0
3)Thread_0 恢复运行
4)设置 _counter 为 0
前面没有调 park 直接调用 unpark 再调用 park
1)调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
2)当前线程调用 Unsafe.park() 方法
3)检查 _counter ,本情况为 1,这时线程无需阻塞,继续运行
4)设置 _counter 为 0
5.1.12.重新理解线程状态转换
假设有线程 Thread t
情况 1 NEW --> RUNNABLE
- 当调用
t.start()
方法时,由NEW --> RUNNABLE
情况 2 RUNNABLE --> WAITING
t 线程用 synchronized(obj)
获取了对象锁后
- 调用
obj.wait()
方法时,t 线程从RUNNABLE --> WAITING
- 调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时- 1)竞争锁成功,t 线程从
WAITING --> RUNNABLE
- 2)竞争锁失败,t 线程从
WAITING --> BLOCKED
- 1)竞争锁成功,t 线程从
@Slf4j(topic = "c.TestWaitNotify")
public class TestWaitNotify {
final static Object obj = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait(); // 让线程在obj上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t1").start();
new Thread(() -> {
synchronized (obj) {
log.debug("执行....");
try {
obj.wait(); // 让线程在obj上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其它代码....");
}
},"t2").start();
// 主线程两秒后执行
sleep(0.5);
log.debug("唤醒 obj 上其它线程");
synchronized (obj) {
// obj.notify(); // 唤醒obj上一个线程
obj.notifyAll(); // 唤醒obj上所有等待线程
}
}
}
情况 3 RUNNABLE --> WAITING
- 当前线程调用
t.join()
方法时,当前线程从RUNNABLE --> WAITING
- 1)注意是当前线程在t 线程对象的监视器上等待
- t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从WAITING --> RUNNABLE
情况 4 RUNNABLE --> WAITING
- 当前线程调用
LockSupport.park()
方法会让当前线程从RUNNABLE --> WAITING
- 调用
LockSupport.unpark
(目标线程) 或调用了线程 的interrupt()
,会让目标线程从WAITING --> RUNNABLE
情况 5 RUNNABLE --> TIMED_WAITING
t 线程用 synchronized(obj) 获取了对象锁后
- 调用
obj.wait(long n)
方法时,t 线程从RUNNABLE --> TIMED_WAITING
- t 线程等待时间超过了 n 毫秒,或调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时- 1)竞争锁成功,t 线程从
TIMED_WAITING --> RUNNABLE
- 2)竞争锁失败,t 线程从
TIMED_WAITING --> BLOCKED
- 1)竞争锁成功,t 线程从
情况 6 RUNNABLE --> TIMED_WAITING
- 当前线程调用
t.join(long n)
方法时,当前线程从 `RUNNABLE --> TIMED_WAITING``- 1)注意是当前线程在t 线程对象的监视器上等待
- 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从TIMED_WAITING --> RUNNABLE
情况 7 RUNNABLE --> TIMED_WAITING
- 当前线程调用 ·Thread.sleep(long n)· ,当前线程从 ·RUNNABLE --> TIMED_WAITING·
- 当前线程等待时间超过了 n 毫秒,当前线程从
TIMED_WAITING --> RUNNABLE
情况 8 RUNNABLE --> TIMED_WAITING
- 当前线程调用
LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long millis)
时,当前线程从RUNNABLE --> TIMED_WAITING
- 调用
LockSupport.unpark(目标线程)
或调用了线程 的interrupt()
,或是等待超时,会让目标线程从
TIMED_WAITING--> RUNNABLE
情况 9 RUNNABLE --> BLOCKED
- t 线程用
synchronized(obj)
获取了对象锁时如果竞争失败,从RUNNABLE --> BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有
BLOCKED
的线程重新竞争,如果其中 t 线程竞争成功,从BLOCKED --> RUNNABLE
,其它失败的线程仍然BLOCKED
情况 10 RUNNABLE <–> TERMINATED
当前线程所有代码运行完毕,进入 TERMINATED
5.1.13.多把锁(细粒度的锁)
多把不相干的锁
一间大屋子有两个功能:睡觉、学习,互不相干。
现在小南要学习,小女要睡觉,但如果只用一间屋子(一个对象锁)的话,那么并发度很低
.
解决方法是准备多个房间(多个对象锁)
例如:
执行:
某次结果:
改进:
public class TestMultiLock {
public static void main(String[] args) {
BigRoom bigRoom = new BigRoom();
new Thread(() -> {
bigRoom.study();
},"小南").start();
new Thread(() -> {
bigRoom.sleep();
},"小女").start();
}
}
@Slf4j(topic = "c.BigRoom")
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);
}
}
}
将锁的粒度细分
- 好处,是可以增强并发度
- 坏处,如果一个线程需要同时获得多把锁,就容易发生死锁
5.1.14.线程活跃性
死锁
有这样的情况:一个线程需要同时获取多把锁,这时就容易发生死锁。死锁条件:互斥,请求并保持,不可剥夺,循环等待
t1 线程
获得 A对象
锁,接下来想获取 B对象
的锁
t2 线程
获得 B对象
锁,接下来想获取 A对象
的锁 例:
@Slf4j(topic = "c.TestDeadLock")
public class TestDeadLock {
public static void main(String[] args) {
test1();
}
private static void test1() {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
log.debug("lock A");
sleep(1);
synchronized (B) {
log.debug("lock B");
log.debug("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
log.debug("lock B");
sleep(0.5);
synchronized (A) {
log.debug("lock A");
log.debug("操作...");
}
}
}, "t2");
t1.start();
t2.start();
}
}
定位死锁
- 检测死锁可以使用 jconsole工具,或者使用 jps 定位进程 id,再用 jstack 定位死锁:
或者用 jconsole 工具
死锁案例之哲学家就餐问题
有五位哲学家,围坐在圆桌旁。(用RentLock解决)
- 他们只做两件事,思考和吃饭,思考一会吃口饭,吃完饭后接着思考。
- 吃饭时要用两根筷子吃,桌上共有 5 根筷子,每位哲学家左右手边各有一根筷子
- 如果筷子被身边的人拿着,自己就得等待
// 就餐
public class TestDeadLock {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
// 哲学家类
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread {
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
// 尝试获得左手筷子
synchronized (left) {
// 尝试获得右手筷子
synchronized (right) {
eat();
}
}
}
}
Random random = new Random();
private void eat() {
log.debug("eating...");
Sleeper.sleep(0.5);
}
}
// 筷子类
class Chopstick {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}
执行不多会,就执行不下去了
使用 jconsole 检测死锁,发现
这种线程没有按预期结束,执行不下去的情况,归类为【活跃性】问题,除了死锁以外,还有活锁和饥饿者两种情况。
活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束(没有阻塞,CPU 还在一直运行),例如
@Slf4j(topic = "c.TestLiveLock")
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();
}
}
饥饿
很多教程中把饥饿定义为,一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束,饥饿的情况不易演示,讲读写锁时会涉及饥饿问题
下面我讲一下我遇到的一个线程饥饿的例子,先来看看使用顺序加锁的方式解决之前的死锁问题
顺序加锁的解决方案
// 就餐
public class TestDeadLock {
public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
// 此处有变动
new Philosopher("阿基米德", c1, c5).start();
}
}
// 哲学家类
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread {
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
// 尝试获得左手筷子
synchronized (left) {
// 尝试获得右手筷子
synchronized (right) {
eat();
}
}
}
}
Random random = new Random();
private void eat() {
log.debug("eating...");
Sleeper.sleep(0.5);
}
}
// 筷子类
class Chopstick {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}
5.1.15.ReentrantLock
相对于 synchronized 它具备如下特点
- 可中断(可通过其他线程取消掉当前的锁)
- 可以设置超时时间(某段时间争抢不到锁,就放弃锁)
- 可以设置为公平锁(防止线程饥饿,先进先出)
- 支持多个条件变量(支持多个 WaitSet)
与 synchronized 一样,都支持可重入
基本语法(需要创建对象,在对象的级别保护,lock 和 unlock 成对)
可重入
可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁;如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。(同一个线程多次获取同等一把锁)
@Slf4j(topic = "c.Test22")
public class Test22 {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
method1();
}
public static void method1() {
lock.lock();
try {
log.debug("execute method1");
method2();
} finally {
lock.unlock();
}
}
public static void method2() {
lock.lock();
try {
log.debug("execute method2");
method3();
} finally {
lock.unlock();
}
}
public static void method3() {
lock.lock();
try {
log.debug("execute method3");
} finally {
lock.unlock();
}
}
}
输出:
可中断
不可中断的意思是等待获取锁的时候不可中断,拿到锁之后可中断,没获取到锁的情况下,中断操作一直不会生效。这里说的可中断指的是在等待锁的阻塞状态是可以通过方法 t1.interrupt();
中断的。lock.lockInterruptibly();
可避免死等,被动唤醒,解决死锁。
@Slf4j(topic = "c.Test22")
public class Test22 {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
log.debug("启动...");
try {
// 如果没有竞争,那么此方法就会获取 lock 对象锁
// 如果有竞争,就进入阻塞队列,可以被其他进程打断,终止等待
lock.lockInterruptibly();
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("没有获得到锁,等锁的过程中被打断");
return;
}
try {
log.debug("获得了锁");
} finally {
lock.unlock();
}
}, "t1");
// 主线程先lock
lock.lock();
log.debug("获得了锁");
t1.start();
try {
sleep(1);
// 打断
t1.interrupt();
log.debug("执行打断");
} finally {
lock.unlock();
}
}
}
用 lock.lock();
锁超时
立刻失败
@Slf4j(topic = "c.Test22")
public class Test22 {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
if (! lock.tryLock()) {
log.debug("获取立刻失败,返回");
return;
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
}, "t1");
// 先对 lock 对象上锁
lock.lock();
log.debug("获得到锁");
t1.start();
sleep(1);
log.debug("释放了锁");
lock.unlock();
}
}
超时失败
@Slf4j(topic = "c.Test22")
public class Test22 {
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("尝试获得锁");
try {
if (! lock.tryLock(1, TimeUnit.SECONDS)) {
log.debug("获取等待 1s 后失败,返回");
return;
}
} catch (InterruptedException e) {
e.printStackTrace();
log.debug("获取不到锁");
return;
}
try {
log.debug("获得到锁");
} finally {
lock.unlock();
}
}, "t1");
// 先对 lock 对象上锁
lock.lock();
log.debug("获得到锁");
t1.start();
sleep(2);
log.debug("释放了锁");
lock.unlock();
}
}
使用 tryLock 解决哲学家就餐问题
@Slf4j(topic = "c.Test23")
public class Test23 {public static void main(String[] args) {
Chopstick c1 = new Chopstick("1");
Chopstick c2 = new Chopstick("2");
Chopstick c3 = new Chopstick("3");
Chopstick c4 = new Chopstick("4");
Chopstick c5 = new Chopstick("5");
new Philosopher("苏格拉底", c1, c2).start();
new Philosopher("柏拉图", c2, c3).start();
new Philosopher("亚里士多德", c3, c4).start();
new Philosopher("赫拉克利特", c4, c5).start();
new Philosopher("阿基米德", c5, c1).start();
}
}
@Slf4j(topic = "c.Philosopher")
class Philosopher extends Thread {
Chopstick left;
Chopstick right;
public Philosopher(String name, Chopstick left, Chopstick right) {
super(name);
this.left = left;
this.right = right;
}
@Override
public void run() {
while (true) {
// 尝试获得左手筷子
// 这里不能产生死等
if(left.tryLock()) {
try {
// 尝试获得右手筷子
// 这里不能产生死等
if(right.tryLock()) {
try {
eat();
} finally {
right.unlock();
}
}
} finally {
left.unlock(); // 释放自己手里的筷子
}
}
}
}
Random random = new Random();
private void eat() {
log.debug("eating...");
Sleeper.sleep(0.5);
}
}
// 筷子当做锁对象
class Chopstick extends ReentrantLock {
String name;
public Chopstick(String name) {
this.name = name;
}
@Override
public String toString() {
return "筷子{" + name + '}';
}
}
公平锁
ReentrantLock 默认是不公平的。(一般不会设置公平锁,会降低并发度,用 tryLock 更好)
条件变量
synchronized 中也有条件变量,就是我们讲原理时那个 waitSet 休息室,当条件不满足时进入 waitSet 等待。
ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比
- synchronized 是那些不满足条件的线程都在一间休息室等消息
- 而 ReentrantLock 支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒
使用要点:
- await 前需要获得锁(首先要成为房间的主人)
- await 执行后,会释放锁,进入 conditionObject 等待
- await 的线程被唤醒
signal();
(或打断、或超时)取重新竞争 lock 锁 - 竞争 lock 锁成功后,从 await 后继续执行
例子:
@Slf4j(topic = "c.Test24")
public class Test24 {
static final Object room = new Object();
static boolean hasCigarette = false;
static boolean hasTakeout = false;
static ReentrantLock ROOM = new ReentrantLock();
/**
* 创建条件变量
*/
// 等待烟的休息室
static Condition waitCigaretteSet = ROOM.newCondition();
// 等外卖的休息室
static Condition waitTakeoutSet = ROOM.newCondition();
public static void main(String[] args) {
new Thread(() -> {
// 获得锁
ROOM.lock();
try {
log.debug("有烟没?[{}]", hasCigarette);
// while f
while (!hasCigarette) {
log.debug("没烟,先歇会!");
try {
// 进入休息室等待
waitCigaretteSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("可以开始干活了");
} finally {
ROOM.unlock();
}
}, "小南").start();
new Thread(() -> {
ROOM.lock();
try {
log.debug("外卖送到没?[{}]", hasTakeout);
while (!hasTakeout) {
log.debug("没外卖,先歇会!");
try {
waitTakeoutSet.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.debug("可以开始干活了");
} finally {
ROOM.unlock();
}
}, "小女").start();
sleep(1);
new Thread(() -> {
// 获取锁
ROOM.lock();
try {
hasTakeout = true;
waitTakeoutSet.signal();
} finally {
ROOM.unlock();
}
}, "送外卖的").start();
sleep(1);
new Thread(() -> {
ROOM.lock();
try {
hasCigarette = true;
waitCigaretteSet.signal();
} finally {
ROOM.unlock();
}
}, "送烟的").start();
}
}
5.1.16. 同步模式之顺序控制
固定运行顺序
比如,必须先 2 后 1 打印
-
1)wait notify 版
@Slf4j(topic = "c.Test25") public class Test25 { private static final Object lock = new Object(); // 表示 t2 是否运行过 static boolean t2runned = false; /** * wait...notify 的正确使用姿势,while + 标记 */ public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lock) { // t2 没有运行过 while (!t2runned) { try { // 进入 WaitSet 中等待 lock.wait(); // CPU 熄火 } catch (InterruptedException e) { e.printStackTrace(); } } log.debug("1"); } }, "t1"); Thread t2 = new Thread(() -> { synchronized (lock) { log.debug("2"); // 设置运行标记 t2runned = true; // 从 WaitSet 中唤醒 t1 线程 lock.notify(); } }, "t2"); t1.start(); t2.start(); } }
-
2)lock中的 await signal 版
TODO -
3)** Park Unpark 版**
可以看到,实现上很麻烦:- 首先,需要保证先 wait 再 notify,否则 wait 线程永远得不到唤醒。因此使用了『运行标记』来判断该不该 wait
- 第二,如果有些干扰线程错误地 notify 了 wait 线程,条件不满足时还要重新等待,使用了 while 循环来解决此问题
- 最后,唤醒对象上的 wait 线程需要使用 notifyAll,因为『同步对象』上的等待线程可能不止一个
可以使用 LockSupport 类的 park 和 unpark 来简化上面的题目:
@Slf4j(topic = "c.Test26") public class Test26 { public static void main(String[] args) { Thread t1 = new Thread(() -> { // 当没有『许可』时,当前线程暂停运行;有『许可』时,用掉这个『许可』,当前线程恢复运行 LockSupport.park(); log.debug("1"); }, "t1"); t1.start(); new Thread(() -> { log.debug("2"); // 给线程 t1 发放『许可』(多次连续调用 unpark 只会发放一个『许可』) LockSupport.unpark(t1); },"t2").start(); } }
交替输出(腾讯校招二面)
线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。现在要求输出 abcabcabcabcabc 怎么实现
-
1)wait notify 版
@Slf4j(topic = "c.Test27") public class Test27 { public static void main(String[] args) { SyncWaitNotify wn = new SyncWaitNotify(1, 5); new Thread(() -> { wn.print(1, 2, "a"); }).start(); new Thread(() -> { wn.print(2, 3, "b"); }).start(); new Thread(() -> { wn.print(3, 1, "c"); }).start(); } } /* 输出内容 等待标记 下一个标记(保证顺序) a 1 2 b 2 3 c 3 1 */ class SyncWaitNotify { // 等待标记 private int flag; // 循环次数 private int loopNumber; public SyncWaitNotify(int flag, int loopNumber) { this.flag = flag; this.loopNumber = loopNumber; } // 打印方法 /** * * @param waitFlag 等待的标记 * @param nextFlag 下一标记 * @param str 打印的内容 */ public void print(int waitFlag, int nextFlag, String str) { for (int i = 0; i < loopNumber; i++) { synchronized (this) { // 公共的标记和我线程传过来的标记是否一样 while (this.flag != waitFlag) { try { this.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.print(str); // 更改公共等待标记 flag = nextFlag; // 唤醒其他的线程 this.notifyAll(); } } } }
-
2)Lock 条件变量版
public class Test30 { public static void main(String[] args) throws InterruptedException { AwaitSignal awaitSignal = new AwaitSignal(5); Condition a = awaitSignal.newCondition(); Condition b = awaitSignal.newCondition(); Condition c = awaitSignal.newCondition(); // 三个线程各自进入休息室 new Thread(() -> { awaitSignal.print("a", a, b); }).start(); new Thread(() -> { awaitSignal.print("b", b, c); }).start(); new Thread(() -> { awaitSignal.print("c", c, a); }).start(); Thread.sleep(1000); awaitSignal.lock(); try { System.out.println("开始..."); // 唤醒 a 休息室中的线程 a.signal(); } finally { awaitSignal.unlock(); } } } class AwaitSignal extends ReentrantLock{ private int loopNumber; public AwaitSignal(int loopNumber) { this.loopNumber = loopNumber; } // 参数1 打印内容, 参数2 进入哪一间休息室, 参数3 下一间休息室 public void print(String str, Condition current, Condition next) { for (int i = 0; i < loopNumber; i++) { lock(); try { current.await(); System.out.print(str); next.signal(); } catch (InterruptedException e) { e.printStackTrace(); } finally { unlock(); } } } }
-
3)Park Unpark 版
@Slf4j(topic = "c.Test31") public class Test31 { static Thread t1; static Thread t2; static Thread t3; public static void main(String[] args) { ParkUnpark pu = new ParkUnpark(5); t1 = new Thread(() -> { pu.print("a", t2); }); t2 = new Thread(() -> { pu.print("b", t3); }); t3 = new Thread(() -> { pu.print("c", t1); }); t1.start(); t2.start(); t3.start(); LockSupport.unpark(t1); } } class ParkUnpark { public void print(String str, Thread next) { for (int i = 0; i < loopNumber; i++) { LockSupport.park(); System.out.print(str); LockSupport.unpark(next); } } private int loopNumber; public ParkUnpark(int loopNumber) { this.loopNumber = loopNumber; } }
5.1.17. 本章小结
本章我们需要重点掌握的是
-
分析多线程访问共享资源时,哪些代码片段属于临界区
-
使用
synchronized
互斥解决临界区的线程安全问题- 1)掌握 synchronized 锁对象语法
- 2)掌握 synchronzied 加载成员方法和静态方法语法
- 3)掌握 wait/notify 同步方法
互斥:是为了保证临界区的代码在上下文切换时,不产生指令的交错,保证临界区代码的一个原子性。
同步:指的是某一个条件不满足时,让线程等待,在条件ma时, -
使用 lock 互斥解决临界区的线程安全问题
- 1)掌握 lock 的使用细节:可打断、锁超时、公平锁、条件变量
-
学会分析变量的线程安全性、掌握常见线程安全类的使用
-
了解线程活跃性问题:死锁、活锁、饥饿
-
应用方面
- 1)互斥:使用 synchronized 或 Lock 达到共享资源互斥效果
- 2)同步:使用 wait/notify 或 Lock 的条件变量来达到线程间通信效果
-
原理方面
- 1)monitor、synchronized 、wait/notify 原理
- 2)synchronized 进阶原理
- 3)park & unpark 原理
-
模式方面
- 1)同步模式之保护性暂停(两个线程之间传递结果,一对一)
- 2)异步模式之生产者消费者(多个线程之间传递结果,非一对一)
- 3)同步模式之顺序控制
5.2.共享模型之内存
原子性、可见性、有序性
本章内容
上一章讲解的 Monitor
主要关注的是访问共享变量时,保证临界区代码的原子性
这一章我们进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题
5.2.1. Java 内存模型
JMM 即 Java Memory Model
,它定义了主存(所有线程都共享的数据,静态成员变量,成员变量)、工作内存(每个线程私有的数据,局部变量)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受到线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
5.2.2. 可见性
退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
@Slf4j(topic = "c.Test32")
public class Test32 {
// 易变
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(true){
if(!run) {
break;
}
}
});
t.start();
sleep(1);
log.debug("停止 t 线程");
run = false; // 线程t不会如预想的停下来
}
}
为什么呢?分析一下:
- 1)初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 2)因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存(物理内存)中 run 的访问,提高效率
- 3)1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值(这就是内存的可见性问题,一个线程对主存的数据进行了修改,对另外一个线程不可见)
解决方法
volatile
(易变关键字,给共享的变量上加入)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
@Slf4j(topic = "c.Test32")
public class Test32 {
// 易变
private static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(true){
if(!run) {
break;
}
}
});
t.start();
sleep(1);
log.debug("停止 t 线程");
run = false; // 线程t不会如预想的停下来
}
}
synchronized
也可以保证可见性,重量级,创建 monitor
@Slf4j(topic = "c.Test32")
public class Test32 {
// 易变
private static boolean run = true;
// 锁对象
private final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(true){
// 保证可见性
synchronized (lock){
// 共享变量放在同步块内
if(!run) {
break;
}
}
}
});
t.start();
sleep(1);
log.debug("停止 t 线程");
// 保证可见性
synchronized (lock){
run = false;
}
}
}
可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况(): 上例从字节码理解是这样的:
比较一下之前我们将线程安全时举的例子:两个线程一个 i++
一个 i--
,只能保证看到最新值,不能解决指令交错。
注意 synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。(synchronized实现可见性:如果有线程加锁的操作那么会清空工作内存,从主存中读取最新的值给工作内存,然后执行完毕把值再更新到主存中去。)
如果在前面示例的死循环中加入System.out.println()
会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?print里面有synchronized修饰。
原理之 CPU 缓存结构
TODO
volatile 改进模式之两阶段终止
利用停止标记
代码:
@Slf4j(topic = "c.TwoPhaseTermination")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
// 创建了多个线程,没意义
tpt.start();
tpt.start();
tpt.start();
Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();
}
}
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
@Slf4j(topic = "c.TwoPhaseTermination")
class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记, volatile 表示共享变量多线程可见
private volatile boolean stop = false;
// 判断是否执行过 start 方法
private boolean starting = false;
// 启动监控线程
public void start() {
synchronized (this) {
// 只创建一次
if (starting) { // false, false 多个线程挡不住,还是会创建多个对象,双检验锁保证单例模式,加 synchronized 已经加了
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}
模式之 Balking (犹豫模式)(如何保证某个类只创建一次)
上面代码的 private boolean starting = false;
和
synchronized (this) {
// 只创建一次
if (starting) { // false, false 多个线程挡不住,还是会创建多个对象,双检验锁保证单例模式,加 synchronized 已经加了
return;
}
starting = true;
}
-
定义
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回 -
实现
例如:public class MonitorService { // 用来表示是否已经有线程已经在执行启动了 private volatile boolean starting; public void start() { log.info("尝试启动监控线程..."); synchronized (this) { if (starting) { return; } starting = true; } // 真正启动监控线程... } }
当前端页面多次点击按钮调用 start 时。
输出:
它还经常用来实现线程安全的单例(懒惰初始化,加的锁太重了,读比较多,写一次,所以读应该不加锁直接返回,写加锁,也就是,双重检验锁保证单例模式)。
对比一下保护性暂停模式:保护性暂停模式用在一个线程等待另一个线程的执行结果,当条件不满足时线程等待。
5.2.3. 有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码。
可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是:
也可以是:
原理之指令级并行
-
1)名词
Clock Cycle Time:主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能够识别的最小时间单位,比如说 4G 主频的 CPU 的 Clock Cycle Time 就是 0.25 ns,作为对比,我们墙上挂钟的 Cycle Time 是 1s 例如,运行一条加法指令一般需要一个时钟周期时间。
CPI:有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数。
IPC:IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
CPU 执行时:程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
-
2) 鱼罐头的故事
加工一条鱼需要 50 分钟,只能一条鱼、一条鱼顺序加工…
可以将每个鱼罐头的加工流程细分为 5 个步骤:- 去鳞清洗 10分钟
- 蒸煮沥水 10分钟
- 加注汤料 10分钟
- 杀菌出锅 10分钟
- 真空封罐 10分钟
即使只有一个工人,最理想的情况是:他能够在 10 分钟内同时做好这 5 件事,因为对第一条鱼的真空装罐,不会影响对第二条鱼的杀菌出锅… (增加了吞吐量) -
3) 指令重排序优化
事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令。为什么这么做呢?可以想到指令还可以再划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段
在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序和组合来实现指令级并行,这一技术在 80’s 中叶到 90’s 中叶占据了计算架构的重要地位。提示:
分阶段,分工是提升效率的关键!指令重排的前提是,重排指令不能影响结果,例如
-
4)支持流水线的处理器
现代 CPU 支持多级指令流水线,例如支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
诡异的结果(在 Java 级别的指令重排序)
I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?
有同学这么分析:
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现:
借助 java 并发压测工具 jcstress :https://wiki.openjdk.java.net/display/CodeTools/jcstress
mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jcstress -
DarchetypeArtifactId=jcstress-java-test-archetype -DarchetypeVersion=0.5 -DgroupId=cn.itcast -
DartifactId=ordering -Dversion=1.0
创建 maven 项目,提供如下测试类:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import org.openjdk.jcstress.infra.results.I_Result;
@JCStressTest
// 预期输出的结果 {1, 4}
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
// 感兴趣的结果
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
执行
mvn clean install
java -jar target/jcstress.jar
会输出我们感兴趣的结果,摘录其中一次结果:
可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。
解决方法
volatile 修饰的变量,可以禁用指令重排。volatile boolean ready = false;
结果为:
在 read 上加
volatile
可以防止之前的代码被重排序。加了一个写屏障。
原理之 volatile
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
1)如何保证可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动(num,ready),都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
2)如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
还是那句话,不能解决指令交错:总结:volatile 解决的是有序性和可见性;synchronize 三者都能做到
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
- 而有序性的保证也只是保证了本线程内相关代码不被重排序
3)double-checked locking 问题(双检验锁实现单例模式)
以著名的 double-checked locking 单例模式为例
以上的实现特点是:
- 懒惰实例化
- 首次使用
getInstance()
才使用synchronized
加锁,后续使用时无需加锁(第一次写加锁,之后读不加锁) - 有隐含的,但很关键的一点:第一个 if 使用了
INSTANCE
变量,是在同步块之外
synchronized 里面的代码还是可以进行重排序的,并不能阻止重排序。也就是说,这行代码
INSTANCE = new Singleton();
中的共享变量完全交给sync来管理是不会有有序性问题的。这里并没有完全的被保护起来,if(INSTANCE == null)
,相当于脱离了保护。
但在多线程环境下,上面的代码是有问题的 if(INSTANCE==null)
是没有sync保护,存在有序性(指令重排)以及可见性问题。getInstance
方法对应的字节码(第一个if 开始之后)为:
其中
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 引用地址
- 21 表示调用构造方法
- 24 表示将对象引用地址,赋值给
static INSTANCE
也许 jvm 会优化为:先执行 24,再执行 21(存在就是先构造对象再引用赋值给变量,还是先引用赋值给变量再构造对象,INSTANCE = new Singleton();
)。如果两个线程 t1,t2 按如下时间序列执行:
关键在于 0: getstatic
这行代码在 monitor
控制之外,它就像之前举例中不守规则的人,可以越过 monitor
读取INSTANCE 变量的值
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效
4)double-checked locking 解决
字节码上看不出来 volatile 指令的效果
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
- 可见性
- 1)写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 2)而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
- 1)写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 2)读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性(刚才造成的原因是:构造方法重排序到了赋值指令的后面)
happens-before 规则
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见(变量都是指成员变量或静态成员变量) - 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见(从主存中去读写)
- 线程 start 前对变量的写,对该线程开始后对该变量的读可见
- 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过
t2.interrupted 或 t2.isInterrupted)
- 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
- 具有传递性,如果
x hb-> y
并且y hb-> z
那么有x hb-> z
,配合 volatile 的防指令重排,有下面的例子
习题
-
1)balking 模式习题
希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么?
-
2)线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题饿汉式:类加载就会导致该单实例对象被创建
懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建实现1:
实现2:
实现3:
实现4:
实现5:
5.2.4. 本章小结
本章重点讲解了 JMM 中的
- 可见性 - 由 JVM 缓存优化引起
- 有序性 - 由 JVM 指令重排序优化引起
- happens-before 规则
- 原理方面
- 1)CPU 指令并行
- 2)volatile(读写屏障)
- 模式方面
- 1)两阶段终止模式的 volatile 改进
- 2)同步模式之 balking(只执行一次)
5.3.共享模型之无锁(乐观锁)
本章内容
- CAS 与 volatile(实现乐观锁)
- 原子整数
- 原子引用
- 原子累加器
- Unsafe
5.3.1.问题提出
有如下需求,保证 account.withdraw
取款方法的线程安全。
package cn.itcast;
import java.util.ArrayList;
import java.util.List;
interface Account {
// 获取余额
Integer getBalance();
// 取款
void withdraw(Integer amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(Account account) {
List<Thread> ts = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(10);
}));
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println(account.getBalance()
+ " cost: " + (end-start)/1000_000 + " ms");
}
}
原有实现并不是线程安全的
class AccountUnsafe implements Account {
private Integer balance;
public AccountUnsafe(Integer balance) {
this.balance = balance;
}
@Override
public Integer getBalance() {
// synchronized (this) {
return this.balance;
// }
}
@Override
public void withdraw(Integer amount) {
// synchronized (this) {
this.balance -= amount;
// }
}
}
执行测试代码
某次的执行结果:
解决思路-锁
首先想到的是给 Account 对象加锁
结果为:
解决思路-无锁
class AccountCas implements Account {
private AtomicInteger balance;
public AccountCas(int balance) {
this.balance = new AtomicInteger(balance);
}
@Override
public Integer getBalance() {
return balance.get();
}
@Override
public void withdraw(Integer amount) {
// 自旋
while(true) {
// 获取余额的最新值
int prev = balance.get();
// 要修改的余额
int next = prev - amount;
// 真正修改
if(balance.compareAndSet(prev, next)) {
break;
}
}
// 可以简化为下面的方法
// balance.getAndAdd(-1 * amount);
}
}
5.3.2.CAS 与 volatile
前面看到的 AtomicInteger
的解决方法,内部并没有用锁来保护共享变量的线程安全。那么它是如何实现的呢?
public void withdraw(Integer amount) {
while(true) {
// 需要不断尝试,直到成功为止
while (true) {
// 获取余额的最新值
int prev = balance.get();
// 要修改的余额
int next = prev - amount;
/*
compareAndSet 正是做这个检查,在 set 前,先比较 prev 与当前值
不一致了,next 作废,返回 false 表示失败
比如,别的线程已经做了减法,当前值已经被减成了 990
那么本线程的这次 990 就作废了,进入 while 下次循环重试
一致,以 next 设置为新值,返回 true 表示成功
*/
// compareAndSet 比较并设置值,内部的实现是原子的,真正修改
if (balance.compareAndSet(prev, next)) {
break;
}
}
}
}
其中的关键是 compareAndSet
,它的简称就是 CAS (也有 Compare And Swap
的说法),它必须是原子操作。(共享变量可见,CAS操作原子性)
注意
其实 CAS 的底层是lock cmpxchg
指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证【比较-交换】的原子性。
在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再
开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。
慢动作分析
volatile
CAS 获取共享变量时(获取最新值),为了保证该变量的可见性,需要使用 volatile 修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
注意
volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
为什么无锁效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。打个比喻
- 线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等被唤醒又得重新打火、启动、加速… 恢复到高速运行,代价比较大。上下文切换有两种情况:时间片轮转和线程的状态转换。
- 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
CAS 的特点
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
- CAS 体现的是无锁并发(不断重试的机制)、无阻塞并发(保证了CAS的线程不断运行,不阻塞导致上下文切换),请仔细体会这两句话的意思
- 1)因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
- 2)但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
5.3.3.原子整数
J.U.C 并发包提供了:
- AtomicBoolean
- AtomicInteger
- AtomicLong
以 AtomicInteger 为例
public class Test34 {
public static void main(String[] args) {
AtomicInteger i = new AtomicInteger(5);
/*System.out.println(i.incrementAndGet()); // ++i 1
System.out.println(i.getAndIncrement()); // i++ 2
System.out.println(i.getAndAdd(5)); // 2 , 7
System.out.println(i.addAndGet(5)); // 12, 12*/
// 读取到 设置值
// i.updateAndGet(value -> value * 10);
System.out.println(updateAndGet(i, p -> p / 2));
// i.getAndUpdate();
System.out.println(i.get());
}
// 自己实现原子整数类的 updateAndGet, IntUnaryOperator 单元运算符的函数式接口
public static int updateAndGet(AtomicInteger i, IntUnaryOperator operator) {
while (true) {
int prev = i.get();
int next = operator.applyAsInt(prev);
if (i.compareAndSet(prev, next)) {
return next;
}
}
}
}
5.3.4.原子引用
为什么需要原子引用类型?(想保护的数据不一定都是基本的数据类型)
- AtomicReference
- AtomicMarkableReference
- AtomicStampedReference
有如下方法
interface DecimalAccount {
// 获取余额
BigDecimal getBalance();
// 取款
void withdraw(BigDecimal amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(DecimalAccount account) {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(BigDecimal.TEN);
}));
}
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println(account.getBalance());
}
}
试着提供不同的 DecimalAccount 实现,实现安全的取款操作:
不安全实现
class DecimalAccountUnsafe implements DecimalAccount {
BigDecimal balance;
public DecimalAccountUnsafe(BigDecimal balance) {
this.balance = balance;
}
@Override
public BigDecimal getBalance() {
return balance;
}
@Override
public void withdraw(BigDecimal amount) {
BigDecimal balance = this.getBalance();
this.balance = balance.subtract(amount);
}
}
安全实现-使用锁
class DecimalAccountSafeLock implements DecimalAccount {
private final Object lock = new Object();
BigDecimal balance;
public DecimalAccountSafeLock(BigDecimal balance) {
this.balance = balance;
}
@Override
public BigDecimal getBalance() {
return balance;
}
@Override
public void withdraw(BigDecimal amount) {
synchronized (lock) {
BigDecimal balance = this.getBalance();
this.balance = balance.subtract(amount);
}
}
}
安全实现-使用 CAS
class DecimalAccountCas implements DecimalAccount {
private AtomicReference<BigDecimal> balance;
public DecimalAccountCas(BigDecimal balance) {
// this.balance = balance;
this.balance = new AtomicReference<>(balance);
}
@Override
public BigDecimal getBalance() {
return balance.get();
}
@Override
public void withdraw(BigDecimal amount) {
while(true) {
BigDecimal prev = balance.get();
BigDecimal next = prev.subtract(amount);
if (balance.compareAndSet(prev, next)) {
break;
}
}
}
}
测试代码:
@Slf4j(topic = "c.Test35")
public class Test35 {
public static void main(String[] args) {
DecimalAccount.demo(new DecimalAccountUnsafe(new BigDecimal("10000")));
DecimalAccount.demo(new DecimalAccountSafeLock(new BigDecimal("10000")));
DecimalAccount.demo(new DecimalAccountCas(new BigDecimal("10000")));
}
}
运行结果:
ABA问题及解决
-
ABA 问题
@Slf4j(topic = "c.Test36") public class Test36 { static AtomicReference<String> ref = new AtomicReference<>("A"); public static void main(String[] args) throws InterruptedException { log.debug("main start..."); // 获取值 A // 这个共享变量被它线程修改过? String prev = ref.get(); other(); sleep(1); // 尝试改为 C log.debug("change A->C {}", ref.compareAndSet(prev, "C")); } private static void other() { new Thread(() -> { log.debug("change A->B {}", ref.compareAndSet(ref.get(), "B")); }, "t1").start(); sleep(0.5); new Thread(() -> { log.debug("change B->A {}", ref.compareAndSet(ref.get(), "A")); }, "t2").start(); } }
输出:
主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况,如果主线程希望:只要有其它线程【动过了】共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号(对共享变量做了修改,版本号就加1)
-
AtomicStampedReference
@Slf4j(topic = "c.Test36") public class Test36 { static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0); public static void main(String[] args) throws InterruptedException { log.debug("main start..."); // 获取值 A String prev = ref.getReference(); // 获取版本号 int stamp = ref.getStamp(); log.debug("版本 {}", stamp); // 如果中间有其它线程干扰,发生了 ABA 现象 other(); sleep(1); // 尝试改为 C log.debug("change A->C {}", ref.compareAndSet(prev, "C", stamp, stamp + 1)); } private static void other() { new Thread(() -> { log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", ref.getStamp(), ref.getStamp() + 1)); log.debug("更新版本为 {}", ref.getStamp()); }, "t1").start(); sleep(0.5); new Thread(() -> { log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", ref.getStamp(), ref.getStamp() + 1)); log.debug("更新版本为 {}", ref.getStamp()); }, "t2").start(); } }
AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:
A -> B -> A ->C
,通过AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了
AtomicMarkableReference
-
AtomicMarkableReference
@Slf4j(topic = "c.Test38") public class Test38 { public static void main(String[] args) throws InterruptedException { GarbageBag bag = new GarbageBag("装满了垃圾"); // 参数2 mark 可以看作一个标记,表示垃圾袋满了 AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true); log.debug("start..."); GarbageBag prev = ref.getReference(); log.debug(prev.toString()); new Thread(() -> { log.debug("start..."); bag.setDesc("空垃圾袋"); ref.compareAndSet(bag, bag, true, false); log.debug(bag.toString()); },"保洁阿姨").start(); sleep(1); log.debug("想换一只新垃圾袋?"); boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false); log.debug("换了么?" + success); log.debug(ref.getReference().toString()); } } class GarbageBag { String desc; public GarbageBag(String desc) { this.desc = desc; } public void setDesc(String desc) { this.desc = desc; } @Override public String toString() { return super.toString() + " " + desc; } }
5.3.5.原子数组
并不修改引用本身,而是修改引用对象里面的内容,此时 AtomicReference
就满足不了要求了,不能针对线程安全里面的内容进行保护了。这里需要说一下,compareAndSet() 的比较是通过引用地址比较的,之前是对String举例,String的不可变性导致了我们每次对String的更改也导致了引用的更改。
原子数组保护的是数组中的元素:
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
有如下方法(例子不太好)
/**
参数1,提供数组、可以是线程不安全数组或线程安全数组
参数2,获取数组长度的方法
参数3,自增方法,回传 array, index
参数4,打印数组的方法
*/
// supplier 提供者 无中生有 ()->结果
// function 函数 一个参数一个结果 (参数)->结果 , BiFunction (参数1,参数2)->结果
// consumer 消费者 一个参数没结果 (参数)->void, BiConsumer (参数1,参数2)->无结果
private static <T> void demo(
Supplier<T> arraySupplier,
Function<T, Integer> lengthFun,
BiConsumer<T, Integer> putConsumer,
Consumer<T> printConsumer ) {
List<Thread> ts = new ArrayList<>();
T array = arraySupplier.get();
int length = lengthFun.apply(array);
for (int i = 0; i < length; i++) {
// 每个线程对数组作 10000 次操作
ts.add(new Thread(() -> {
for (int j = 0; j < 10000; j++) {
putConsumer.accept(array, j % length);
}
}));
}
ts.forEach(t -> t.start()); // 启动所有线程
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}); // 等所有线程结束
printConsumer.accept(array);
}
不安全的数组
public static void main(String[] args) {
demo(
()->new int[10],
(array)->array.length,
(array, index) -> array[index]++,
array-> System.out.println(Arrays.toString(array))
);
}
结果
安全的数组
public static void main(String[] args) {
demo(
()-> new AtomicIntegerArray(10),
(array) -> array.length(),
(array, index) -> array.getAndIncrement(index),
array -> System.out.println(array)
);
}
结果
5.3.6.字段更新器
字段更新器保护的是某个对象里面的属性,或者成员变量。
- AtomicReferenceFieldUpdater // 域 字段是引用数据类型
- AtomicIntegerFieldUpdater
- AtomicLongFieldUpdater
利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常。
@Slf4j(topic = "c.Test40")
public class Test40 {
public static void main(String[] args) {
Student stu = new Student();
AtomicReferenceFieldUpdater updater =
AtomicReferenceFieldUpdater.newUpdater(Student.class, String.class, "name");
System.out.println(updater.compareAndSet(stu, null, "张三"));
System.out.println(stu);
}
}
class Student {
volatile String name;
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
'}';
}
}
5.3.7.原子累加器
累加器性能比较
专门做累计的累加器 LongAdder
public class Test41 {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
demo(
() -> new AtomicLong(0),
(adder) -> adder.getAndIncrement()
);
}
for (int i = 0; i < 5; i++) {
demo(
() -> new LongAdder(),
adder -> adder.increment()
);
}
}
/**
*
* @param adderSupplier Supplier () -> 结果 提供累加器对象
* @param action Consumer (参数) -> 执行累加操作
* @param <T> 泛型
*/
private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {
T adder = adderSupplier.get();
List<Thread> ts = new ArrayList<>();
// 4 个线程,每人累加 50 万
for (int i = 0; i < 4; i++) {
ts.add(new Thread(() -> {
for (int j = 0; j < 500000; j++) {
action.accept(adder);
}
}));
}
long start = System.nanoTime();
ts.forEach(t -> t.start());
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println(adder + " cost:" + (end - start) / 1000_000);
}
}
性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0 累加 Cell[0],而 Thread-1 累加 Cell[1]… (最多不超过CPU核心数,CAS有CPU空转的情形)最后将结果汇总。这样它们在累加时操作的不同的 Cell 共享变量,因此减少了 CAS 重试失败,从而提高性能。
源码之 LongAdder
LongAdder 是并发大师 @author Doug Lea (大哥李)的作品,设计的非常精巧。
LongAdder 类有几个关键域:
Cas锁(与 LongAdder 中的 cellBusy 类似,第一个线程来了要进行cell数组扩容,那第二个线程就被阻塞,不能扩容)
/**
* 不要用于实践
*/
@Slf4j(topic = "c.Test42")
public class LockCas {
// 0 没加锁
// 1 加锁
private AtomicInteger state = new AtomicInteger(0);
// CPU 空转
public void lock() {
while (true) {
if (state.compareAndSet(0, 1)) {
break;
}
}
}
// 只有锁的持有者线程能够执行
public void unlock() {
log.debug("unlock...");
// 设置锁状态为0
state.set(0);
}
// 测试
public static void main(String[] args) {
LockCas lock = new LockCas();
new Thread(() -> {
log.debug("begin...");
lock.lock();
try {
log.debug("lock...");
sleep(1);
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
log.debug("begin...");
// 被挡住了,CPU 空循环
lock.lock();
try {
log.debug("lock...");
} finally {
lock.unlock();
}
}).start();
}
}
原理之伪共享
其中 Cell 即为累加单元。
得从缓存说起
缓存与内存的速度比较
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。
而缓存(容量小)以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)
缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行(64 字节)必须失效。
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行(64字节)可以存下 2 个的 Cell 对象。这样问题来了:
- Core-0 要修改 Cell[0]
- Core-1 要修改 Cell[1]
无论谁修改成功,都会导致对方 Core 的缓存行失效,比如 Core-0 中 Cell[0]=6000, Cell[1]=8000
要累加Cell[0]=6001, Cell[1]=8000
,这时会让 Core-1 的缓存行失效,伪共享,需要从内存中去更新 cell0,让两个 cell分布在不同的缓存行,局部更新
@sun.misc.Contended
用来解决这个问题,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding(空隙),从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效。防止伪共享。
累加主要调用下面的方法(TODO,LongAdder的源码没听懂)
public void add(long x) {
// as 为累加单元数组
// b 为基础值
// x 为累加值
// Cell[] 懒惰创建,Cell[]为空,当没有竞争时,用 base作基本的累加
Cell[] as; long b, v; int m; Cell a;
// 进入 if 的两个条件
// 1. as 有值, 表示已经发生过竞争, 进入 if
// 2. cas 给 base 累加时失败了, 表示 base 发生了竞争, 进入 if
if ((as = cells) != null || !casBase(b = base, b + x)) {
// uncontended 表示 cell 没有竞争
boolean uncontended = true;
if (
// as 还没有创建
as == null || (m = as.length - 1) < 0 ||
// 当前线程对应的 cell 还没有创建
(a = as[getProbe() & m]) == null ||
// cas 给当前线程的 cell 累加失败 uncontended=false ( a 为当前线程的 cell )
!(uncontended = a.cas(v = a.value, v + x))
) {
// 进入 cell 数组创建、cell 创建的流程
longAccumulate(x, null, uncontended);
}
}
}
add 流程图
// 累加数组或者累加单元没有创建,都会进入此方法
final void longAccumulate(long x, LongBinaryOperator fn,
boolean wasUncontended) {
int h;
// 当前线程还没有对应的 cell, 需要随机生成一个 h 值用来将当前线程绑定到 cell
if ((h = getProbe()) == 0) {
// 初始化 probe
ThreadLocalRandom.current();
// h 对应新的 probe 值, 用来对应 cell
h = getProbe();
wasUncontended = true;
}
// collide 为 true 表示需要扩容
boolean collide = false;
for (;;) {
Cell[] as; Cell a; int n; long v;
// 已经有了 cells,累加单元数组创建好了
if ((as = cells) != null && (n = as.length) > 0) {
// 还没有 cell
if ((a = as[(n - 1) & h]) == null) {
// 为 cellsBusy 加锁, 创建 cell, cell 的初始累加值为 x
// 成功则 break, 否则继续 continue 循环
}
// 有竞争, 改变线程对应的 cell 来重试 cas
else if (!wasUncontended)
wasUncontended = true;
// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 null
else if (a.cas(v = a.value, ((fn == null) ? v + x : fn.applyAsLong(v, x))))
break;
// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 cas
else if (n >= NCPU || cells != as)
collide = false;
// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了
else if (!collide)
collide = true;
// 加锁
else if (cellsBusy == 0 && casCellsBusy()) {
// 加锁成功, 扩容
continue;
}
// 改变线程对应的 cell
h = advanceProbe(h);
}
// 还没有 cells, 尝试给 cellsBusy 加锁(创建cells数组必须要加锁,CAS 加锁采用标记位 cellsBusy)
// cells == as 表示还没有其他线程改变这个数组(as是最初读到的数组,cells是新数组)
// casCellsBusy() 表示进行尝试加锁
else if (cellsBusy == 0 && cells == as && casCellsBusy()) {
// .... TODO
// 加锁成功, 初始化 cells, 最开始长度为 2, 并填充一个 cell
// 成功则 break;
}
// 上两种情况失败(加锁失败,或者有其他的线程已经创建了cells), 尝试给 base 累加
else if (casBase(v = base, ((fn == null) ? v + x : fn.applyAsLong(v, x))))
break;
}
}
longAccumulate 流程图
每个线程刚进入 longAccumulate 时,会尝试对应一个 cell 对象(找到一个坑位)
获取最终结果通过 sum 方法
5.3.8.Unsafe
概述
Unsafe 对象提供了非常底层的,操作内存、线程的方法(所以叫做不安全类,也叫作魔法类,不是指线程不安全类),Unsafe 对象不能直接调用,只能通过反射获得。
public class UnsafeAccessor {
static Unsafe unsafe;
static {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
unsafe = (Unsafe) theUnsafe.get(null);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new Error(e);
}
}
static Unsafe getUnsafe() {
return unsafe;
}
}
Unsafe CAS 操作
public class TestUnsafe {
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// getDeclaredField 反射获取私有的成员变量 theUnsafe
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
// 私有的域,允许访问私有的构造方法
theUnsafe.setAccessible(true);
// theUnsafe 是静态的,不需要传一个实际的对象,传 null 就行
Unsafe unsafe = (Unsafe) theUnsafe.get(null);
System.out.println(unsafe);
// 1. 获取域(属性)的偏移地址(此处的偏移量是固定值,是类的,是字节码中的,可以用实际的对象,怼出实际属性的实际地址)
long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));
long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));
Teacher t = new Teacher();
// 2. 执行 cas 操作,多线程下虚妄成功需要用 while 重试
unsafe.compareAndSwapInt(t, idOffset, 0, 1);
unsafe.compareAndSwapObject(t, nameOffset, null, "张三");
// 3. 验证
System.out.println(t);
}
}
@Data
class Teacher {
// 利用线程安全的形式来对成员变量进行修改(unsafe 的底层实现)通过定位到内存的地址来找到属性,从而进行 CAS
// 也可以用高层的方法 AtomicReferenceFieldUpdater 来对对象中的属性修改
volatile int id;
volatile String name;
}
使用自定义的 AtomicData 实现之前线程安全的原子整数 Account 实现
@Slf4j(topic = "c.Test42")
public class Test42 {
public static void main(String[] args) {
Account.demo(new MyAtomicInteger(10000));
}
}
class MyAtomicInteger implements Account {
// 要保护的整型的成员变量,value 需要配合 CAS 一起使用,声明为 volatile 修饰
private volatile int value;
// 计算出 value 偏移量
private static final long valueOffset;
// UNSAFE
private static final Unsafe UNSAFE;
static {
// 通过反射获取 UNSAFE 对象
UNSAFE = UnsafeAccessor.getUnsafe();
try {
// 获取了要保护的域的偏移量
valueOffset = UNSAFE.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));
} catch (NoSuchFieldException e) {
e.printStackTrace();
throw new RuntimeException(e);
}
}
public int getValue() {
return value;
}
/**
* @param amount 减掉 CAS 操作
*/
public void decrement(int amount) {
// CPU 自旋
while(true) {
int prev = this.value;
int next = prev - amount;
// CAS 赋值,直接操作内存
if (UNSAFE.compareAndSwapInt(this, valueOffset, prev, next)) {
break;
}
}
}
public MyAtomicInteger(int value) {
this.value = value;
}
@Override
public Integer getBalance() {
return getValue();
}
@Override
public void withdraw(Integer amount) {
decrement(amount);
}
}
interface Account {
// 获取余额
Integer getBalance();
// 取款
void withdraw(Integer amount);
/**
* 方法内会启动 1000 个线程,每个线程做 -10 元 的操作
* 如果初始余额为 10000 那么正确的结果应当是 0
*/
static void demo(Account account) {
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
ts.add(new Thread(() -> {
account.withdraw(10);
}));
}
long start = System.nanoTime();
ts.forEach(Thread::start);
ts.forEach(t -> {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.nanoTime();
System.out.println(account.getBalance()
+ " cost: " + (end-start)/1000_000 + " ms");
}
}
5.3.9.本章小结
- CAS 与 volatile
- API
- 1)原子整数
- 2)原子引用
- 3)原子数组
- 4)字段更新器
- 5)原子累加器
- Unsafe
- 原理方面
- 1)LongAdder 源码 TODO
- 2)伪共享
5.4.共享模型之不可变
本章内容
- 不可变类的使用
- 不可变类设计
- 无状态类设计
5.4.1.日期转换的问题
问题提出
下面的代码在运行时,由于 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 或者出现不正确的日期解析结果,例如:
思路 - 同步锁
这样虽能解决问题,但带来的是性能上的损失,并不算很好: synchronized (sdf)
思路 - 不可变
如果一个对象在不能够修改其内部状态(属性),那么它就是线程安全的,因为不存在并发修改啊!这样的对象在Java 中有很多,例如在 Java 8 后,提供了一个新的日期格式化类:
@Slf4j(topic = "c.Test1")
public class Test1 {
public static void main(String[] args) {
// DateTimeFormatter => This class is immutable and thread-safe.
DateTimeFormatter stf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
TemporalAccessor parse = stf.parse("1951-04-21");
log.debug("{}", parse);
}).start();
}
}
private static void test() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
synchronized (sdf) {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}
}).start();
}
}
}
不可变对象,实际是另一种避免竞争的方式。
5.4.2. 不可变设计
另一个大家更为熟悉的 String 类也是不可变的,以它为例,说明一下不可变设计的要素。
final 的使用
发现该类、类中所有属性都是 final 的
- 属性用 final 修饰保证了该属性是只读的,不能修改
- 类用 final 修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性
保护性拷贝
但有同学会说,使用字符串时,也有一些跟修改相关的方法啊,比如 substring 等,那么下面就看一看这些方法是如何实现的,就以 substring 为例:
发现其内部是调用 String 的构造方法创建了一个新字符串,再进入这个构造看看,是否对 final char[] value 做出了修改:
结果发现也没有,构造新字符串对象时,会生成新的 char[] value,对内容进行复制 。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】(会带来一个问题,对象创建的太过于频繁,对象创建的很多,一般会将对象关联一个模式)
模式之享元
1)简介
定义 英文名称:Flyweight pattern. 当需要重用数量有限的同一类对象时(字符串常量池)
wikipedia: A flyweight is an object that minimizes memory usage by sharing as much data as
possible with other similar objects
出自 “Gang of Four” design patterns
归类 Structual patterns
2)体现
-
包装类
在JDK中 Boolean,Byte,Short,Integer,Long,Character 等包装类提供了 valueOf 方法,例如 Long 的 valueOf 会缓存 -128~127 之间的 Long 对象,在这个范围之间会重用对象,大于这个范围,才会新建 Long 对象:private static class LongCache { static final Long[] cache = new Long[256]; private LongCache() { } static { for(int i = 0; i < cache.length; ++i) { cache[i] = new Long((long)(i - 128)); } } } public static Long valueOf(long l) { int offset = true; return l >= -128L && l <= 127L ? Long.LongCache.cache[(int)l + 128] : new Long(l); }
注意:
- Byte, Short, Long 缓存的范围都是 -128~127
- Character 缓存的范围是 0~127
- Integer的默认范围是 -128~127
- 1)最小值不能变
- 2)但最大值可以通过调整虚拟机参数
-Djava.lang.Integer.IntegerCache.high
来改变- Boolean 缓存了 TRUE 和 FALSE
- String 串池(线程安全,不可变类,保护性拷贝)
- BigDecimal BigInteger(这个不可变类,但是在取款中这个明明是不安全的呀,因为多个方法的组合并不能保证线程安全)
3)DIY(TODO手写)
例如:一个线上商城应用,QPS 达到数千,如果每次都重新创建和关闭数据库连接,性能会受到极大影响。 这时预先创建好一批连接,放入连接池。一次请求到达后,从连接池获取连接,使用完毕后再还回连接池(包含获取连接方法以及归还连接方法是线程安全的),这样既节约了连接的创建和关闭时间,也实现了连接的重用,不至于让庞大的连接数压垮数据库。
public class Test3 {
public static void main(String[] args) {
Pool pool = new Pool(2);
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Connection conn = pool.borrow();
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
pool.free(conn);
}).start();
}
}
}
@Slf4j(topic = "c.Pool")
class Pool {
// 1. 连接池大小
private final int poolSize;
// 2. 连接对象数组
private Connection[] connections;
// 3. 连接状态数组 0 表示空闲, 1 表示繁忙 原子整数数组来实现线程安全(因为有多个线程想改这个数组的数据)
// 此处不能用 Integer 因为包装类中的单个方法是线程安全的,这里涉及到 CAS 操作,有比较
private AtomicIntegerArray states;
// 4. 构造方法初始化
public Pool(int poolSize) {
this.poolSize = poolSize;
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = new MockConnection("连接" + (i+1));
}
}
// 5. 借连接
public Connection borrow() {
while(true) {
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
// 不能用 states.set(i, 1); 存在线程安全
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 如果没有空闲连接,当前线程进入等待(为什么要sync?因为CAS 会导致CPU空转,适合短时间,此处的连接有业务需求,耗时长,不能让CPU一直空转)
synchronized (this) {
try {
log.debug("wait...");
// 放弃 CPU 资源
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
// 此处没有线程安全问题,因为只有一个线程是持有者
states.set(i, 0);
synchronized (this) {
log.debug("free {}", conn);
this.notifyAll();
}
break;
}
}
}
}
class MockConnection implements Connection {
private String name;
public MockConnection(String name) {
this.name = name;
}
@Override
public String toString() {
return "MockConnection{" +
"name='" + name + '\'' +
'}';
}
// TODO 下面还有很多默认重写的方法,此处省略...
}
以上实现没有考虑:
- 连接的动态增长与收缩
- 连接保活(可用性检测,连接可能因为网络等原因断开等)
- 等待超时处理(wait 中存在死等)
- 分布式 hash
对于关系型数据库,有比较成熟的连接池实现,例如c3p0, druid等 对于更通用的对象池,可以考虑使用apache commons pool,例如redis连接池可以参考jedis中关于连接池的实现
原理之 final
1)设置 final 变量的原理
理解了 volatile 原理,再对比 final 的实现就比较简单了
字节码
发现 final 变量的赋值也会通过 putfield 指令来完成,同样在这条指令之后也会加入写屏障,保证在其它线程读到它的值时不会出现为 0 的情况
2)获取 final 变量的原理(在编译期直接优化)
5.4.3. 无状态
在 web 阶段学习时,设计 Servlet 时为了保证其线程安全,都会有这样的建议,不要为 Servlet 设置成员变量,这种没有任何成员变量的类是线程安全的(没有成员变量就意味着没有共享变量)
因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】
5.4.4. 本章小结
- 不可变类使用
- 不可变类设计
- 原理方面 final
- 模式方面 享元
剩下的笔记请看:计算机笔记–【并发编程②】