【二】多线程 —— 共享模型
引子
两个线程对初始值为 0 的同一个变量分别做自增和自减,各执行5000次,这个变量结果还是不是0?
public class AddMinus5000TimeEach {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i<5000; i++){
counter++;
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i<5000; i++){
counter--;
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
Console.log(counter);
}
}
-2405
搞错了,再来
1623
执行了多次,以上的结果可能是正数、负数、零。
为什么会这样?
首先,Java的内存模型如下,完成静态变量的自增和自减都需要在主存和工作内存内进行数据交换:
然后,从字节码的角度,上面代码涉及到8条字节码指令:
//i++对应字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
//i--对应字节码指令
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
若是单线程代码是顺序执行不会有问题:
但现在是两个线程分别执行加法和减法:
上面是以出现负数为例:
- 当 CPU 时间片分给
t2
线程时,t2
线程去读取主存中的变量值为0
并且执行--
的操作,操作结果是-1
。 - 但是上面减的结果
-1
还没来得及写入主存,这个时候t1
的CPU时间片到了,完成了一次上下文切换,这个时候t1
线程执行加的任务 t1
从主存获取的还是0
(t2
的值还未写入主存),执行加法操作结果是1
,然后写入主存- 这个时候又完成了一次上下文切换,
t2
线程又获得CPU时间片,这个时候会将之前减的结果-1
放入主存,覆盖了t1
的结果1
- 这样一个流程下来,执行了一次加、一次减,但是得到的结果却是负数,不是0
根本原因就是对共享资源写操作时,CPU时间片切换(上下文切换)时出现的线程间指令交错。
换句话说,
- 要是单线程时不会出现这种问题;
- 多个线程访问的不是共享变量也不会有问题;
- 多个线程访问的是共享变量,但只有读的操作也不会有问题;
- 多个线程访问的是共享变量,有写的操作,但是不发生指令交错也不会有问题;
那我们可以知道,多线程的编码过程就是要用各种方式来规避这种上下文切换时带来的指令交错现象,避免竞态条件的发生。
为了避免临界区中的竞态条件发生,由多种手段可以达到。
- 阻塞式解决方案:synchronized ,Lock
- 非阻塞式解决方案:原子变量
一、synchronized 解决方案
现在讨论使用 synchronized
来进行解决,即俗称的对象锁。
它采用互斥
的方式让同一时刻至多只有一个线程持有对象锁
,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。
Java中的互斥和同步都可以采用 synchronized
关键字来完成,但它们还是有区别的:
互斥
是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码同步
是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
改写上面的例子:
public class AddMinus5000TimeEachSynchronized {
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i<5000; i++){
synchronized (room){
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i<5000; i++){
synchronized (room){
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
Console.log(counter);
}
}
0
可以看到执行多次结果都是 0
了。
可以这样类比:
synchronized
(对象) 中的对象,可以想象为一个房间(room),有唯一入口(门)房间只能一次进入一人进行计算,线程 t1,t2 想象成两个人。当线程 t1 执行到synchronized
(room) 时就好比 t1 进入了这个房间,并锁住了门拿走了钥匙,在门内执行count++
代码- 这时候如果 t2 也运行到了
synchronized
(room) 时,它发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了。这中间即使 t1 的 cpu 时间片不幸用完,被踢出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这时门还是锁住的,t1 仍拿着钥匙,t2 线程还在阻塞状态进不来,只有下次轮到 t1 自己再次获得时间片时才能开门进入 - 当 t1 执行完
synchronized{}
块内的代码,这时候才会从 obj 房间出来并解开门上的锁,唤醒 t2 线程把钥匙给他。t2 线程这时才可以进入 obj 房间,锁住了门拿上钥匙,执行它的 count–
1.1 synchronized 保证临界区代码的原子性
synchronized
实际是用对象锁
保证了临界区
内代码的原子性
,临界区内的代码对外是不可分割的,不会被线程切换所打断。
怎么理解上面的话?比如synchronized(obj)
放在for循环外,就是相当于里面的5千条指令都是原子的,不会被其他线程干扰;放在里面只是说那几条指令是原子的,不会被其他线程干扰:
public class AddMinus5000TimeEachSynchronized02 {
static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (room){
for (int i = 0; i<5000; i++){
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (room){
for (int i = 0; i<5000; i++){
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
Console.log(counter);
}
}
0
结果也是一样的。
虽然结果一样,但是实际中效果一样的条件下,尽量保证锁的代码范围尽可能的少。
1.2 面向对象改进
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
return value;
}
}
public class AddMinus5000TimeEachSynchronized03 {
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();
Console.log("count: {}" , room.get());
}
}
count: 0
1.3 synchronized 格式
synchronized
// 普通方法上
**class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}**
synchronized
加在普通成员方法上,锁住的是对象synchronized
加在静态方法上,锁住的是类
1.4 线程八锁
其实就是看synchronized
锁住的是啥。
①
class Number{
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
锁住的为同一个Number
类型的对象,2个线程都有可能执行。
结果打印:12 或 21
②
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
锁住的为同一对象,2个线程都有可能执行
结果打印:1s后12,或 2 1s后 1
③
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
public void c() {
log.debug("3");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
new Thread(()->{ n1.c(); }).start();
}
锁住的为同一对象,3个线程都有可能执行。
结果打印:3 1s 12 或 23 1s 1 或 32 1s 1
④
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
锁住的不为同一对象,不存在锁竞争,第二个线程先执行。
结果打印:2 1s 后 1
⑤
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
锁住的不为同一对象,不存在锁竞争,第二个线程先执行,第一个锁的是类,第二个是对象
结果打印:2 1s 后 1
⑥
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
锁住的为同一个类,2个线程都有可能执行
结果打印:1s 后12, 或 2 1s后 1
⑦
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
锁住的不为同一对象,不存在锁竞争,第二个线程先执行
结果打印:2 1s 后 1
⑧
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
锁住的为同一对象,2个线程都有可能执行
结果打印:1s 后12, 或 2 1s后 1
二、变量的线程安全分析
2.1 成员变量和静态变量的线程安全分析
(1)如果变量没有在线程间共享,那么线程对该变量操作是安全的
(2)如果变量在线程间共享
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码就是临界区,需要考虑线程安全问题。比如若对象是单例的,那成员变量也可能是共享的,在临界区有写的操作就要注意线程安全性。
2.2 局部变量线程安全分析
(1)局部变量【局部变量被初始化为基本数据类型
】是安全的,因为每个线程调用某个方法时在虚拟机栈产生栈帧,线程私有,多个线程就有多个,是不被多个线程共享的,所有就没有线程安全问题。
(2)局部变量是引用类型
- 如果该对象没有逃离方法的作用范围,线程安全
- 如果该对象逃离了方法的作用范围,需要考虑线程安全问题
2.3 成员变量的线程安全性
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
// { 临界区, 会产生竞态条件(为什么是临界区?因为这里list是共享变量,且是对共享变量的写操作)
method2();
method3();
// } 临界区
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
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();
}
}
}
Exception in thread "Thread1" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
at java.util.ArrayList.rangeCheck(ArrayList.java:657)
at java.util.ArrayList.remove(ArrayList.java:496)
at thread.demo.ThreadUnsafe.method3(ThreadUnsafe.java:19)
at thread.demo.ThreadUnsafe.method1(ThreadUnsafe.java:11)
at thread.demo.ThreadUnsafe.lambda$main$0(ThreadUnsafe.java:29)
at java.lang.Thread.run(Thread.java:748)
报错分析
因为list是成员变量,无论哪个线程中的 method2 引用的都是同一个堆中的 list 成员变量。
此时list存在并发问题,因为多个Thread使用的同一个list,在并发情况下add操作可能被覆盖,导致remove的比add的多从而报错。
其中一种是,线程2还未add,线程1就开始remove了,那就会报上面的错。
修改成局部变量就没有问题了:
class ThreadUnsafeLocal {
public 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) {
list.remove(0);
}
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafeLocal test = new ThreadUnsafeLocal();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
test.method1(LOOP_NUMBER);
}, "Thread" + i).start();
}
}
}
为啥list变成局部变量就可以了?
list 是局部变量,每个线程调用时会创建其不同实例,没有共享,所以list不存在并发问题。而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象。
2.4 子类继承父类后线程不安全示例
对上方代码的method3进行重写后,出现线程不安全。
class ThreadUnsafeSon {
public static void main(String[] args) {
ThreadSafeSubClass test = new ThreadSafeSubClass();
for(int i = 0 ; i < 1; i++){
new Thread(()->{
test.method1(20000);
}, "Thread" + (i + 1)).start();
}
}
}
class ThreadSafe {
public 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");
System.out.println((Thread.currentThread() + "1"));
}
public void method3(ArrayList<String> list) {
list.remove(0);
System.out.println((Thread.currentThread() + "0"));
}
}
class ThreadSafeSubClass extends ThreadSafe{
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
System.out.println((Thread.currentThread() + "0"));
}).start();
}
}
上面的代码线程不安全,因为method2
和method3
都是public修饰的,那么在新建子类后,就可以重写的method2
和method3
方法,参数list是从父类而来,所以是同一个资源,执行写的时候,那就会线程不安全。
改成private
修饰后,方法对子类不可见;或者final
修饰,不能被子类重写,就不会有线程安全问题,这就是权限修饰的作用之一。
2.5 局部变量线程安全性分析
public static void test() {
int i = 10;
i++;
}
每个线程在调用这个方法时,会在自己的栈中生成栈帧,多线程就是创建多个栈帧,这个i
就不是共享的了,因此不会有线程安全问题。
三、常见线程安全类
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的但注意它们多个方法的组合不是原子的。例如:
上面只是get和put方法内的是线程安全的,组合之后就不安全了。后一个put的值覆盖了前面的。
四、线程安全实例分析
例1
例2
例3
例4
例5
例6
例7
五、Monitor
前面介绍了synchronized
可以实现锁的功能,下面要介绍它为什么可以锁住对象。
Monitor
被翻译为监视器或管程。
每个 Java 对象都可以关联一个 Monitor
对象,如果使用 synchronized
给对象上锁(重量级)之后,该对象头的Mark Word
中就被设置指向 Monitor
对象的指针。
Monitor
对象有三个集合属性:waitSet、EntryList、Owner
Monitor
结构如下:
- 刚开始
Monitor
中Owner
为null
- 当
Thread-2
执行synchronized(obj)
就会将Monitor
的所有者Owner
置为Thread-2
,Monitor
中只能有一 个Owner
- 在
Thread-2
上锁的过程中,如果Thread-3
,Thread-4
,Thread-5
也来执行synchronized(obj)
,就会进入EntryList
,状态变成BLOCKED
Thread-2
执行完同步代码块的内容,然后唤醒EntryList
中等待的线程来竞争锁,竞争的时是非公平的- 图中
WaitSet
中的Thread-0
,Thread-1
是之前获得过锁,但条件不满足进入WAITING
状态的线程
synchronized
必须是进入同一个对象的 monitor
才有上述的效果,不加 synchronized
的对象不会关联监视器,不遵从以上规则。
在加锁的时候,obj
是Java
对象而Monitor
是操作系统提供的对象。每个 Java 对象都可以关联一个 Monitor
对象,如果使用 synchronized
给对象上锁(重量级)之后,该对象头的Mark Word
中就被设置指向 Monitor
对象的指针。
5.1 对象头
以32位JVM为例:
普通对象:
数组对象:
Klass word
是一个指针指向了这个对象所从属的class(即通过klass word
找到它的类对象)。每个对象都有一个类型,例如student类型。
其中Mark Word
结构为:
普通对象的header保存的就是它的hashcode,age,加锁状态等。
【结合下完整的加锁时关联monitor流程】
上面obj
就是加锁的对象,它的Mark word
里面会指向一个Monitor
对象。此时Mark word
的内容如下:
这时就能知道synchronized
的原理了,就是Heavyweight Locked
这里指向了一个Monitor
对象。
指向之后,thread2
就成了这个Monitor
对象的拥有者:
若有多个线程:
thread1
过来要执行临界区代码,发现obj
的Mark Word
区域已经指向了一个Monitor
对象,且是拥有者,thread1
就会进入这Monitor
对象的EntryList
中排队等候,同理Thread3
也是进入EntryList
中排队等候,它们都会变成BLOCKED
状态。
Thread2
线程执行完临界区的代码就会让出Owner
的位置:
然后就会通知EntryList
中排队的线程,叫醒它们,具体唤醒哪个线程要看唤醒策略。
假设唤醒的是Thread2
,那它就会成为新的Owner
,Thread3
线程继续在EntryList
中排队。
因此锁的对象都是和Monitor
对象关联的,同一个锁对象关联的是同一个Monitor
对象。
六、轻量级锁
我们知道synchronized
原理里的锁实际上是锁对象关联monitor
,但是monitor
是操作系统所提供的,要使用它是本是比较高的。从JDK6开始对获取锁的方式进行了改进优化,从使用monitor
锁,改进了可以使用轻量级锁
、偏向锁
。
6.1 小故事
故事角色
■老王-操作系统
■小南-线程
■小女-线程
■房间-对象
■房间门上-防盗锁- Monitor
■房间门上-小南书包-轻量级锁
■房间门上-刻上小南大名-偏向锁
■批量重刻名-一个类的偏向锁撤销到达20阈值
■不能刻名字-批量撤销该类对象的偏向锁,设置该类不可偏向
【synchronized重量级锁】小南要使用房间保证计算不被其它人干扰(原子性),最初,他用的是防盗锁(monitor),当cpu时间片用完了上下文切换时,锁住门。这样,即使他离开了,别人也进不了门,他的工作就是安全的。但是,很多情况下没人跟他来竞争房间的使用权。小女是要用房间,但使用的时间上是错开的,小南白天用,小女晚上用。每次上锁太麻烦了,有没有更简单的办法呢?
【轻量级锁】小南和小女商量了一下,约定不锁门了,而是谁用房间,谁把自己的书包挂在门口,但他们的书包样式都一样,因此每次进前得翻翻书包【轻量级锁】,看课本是谁的,如果是自己的,那么就可以进门,这样省的上锁解锁了。万一书包不是自己的,那么就在门外等,并通知对方下次用锁1门的方式。(轻量级锁,性能是会得到提升的)
【偏向锁】后来,小女回老家了,很长一段时间都不会用这个房间。 小南每次还是挂书包,翻书包,虽然比锁门省事
了,但仍然觉得麻烦。于是,小南干脆在门上刻上了自己的名字【偏向锁】: [小南专属房间, 其它人勿用],下次来用房间时,只要名字还在,那么说明没人打扰,还是可以安全地使用房间。如果这期间有其它人要用这个房间,那么由使用者将小南刻的名字擦掉,升级为挂书包的方式。(偏向锁:房间专属于某个线程使用,偏向于某个线程,这种锁叫偏向锁,偏向锁的优化:如果有人和它竞争,那么偏向锁会被撤销掉,然后变成更进一步的轻量级锁的方式)
【批量重偏向】同学们都放假回老家了,小南就膨胀了,在20个房间刻上了自己的名字,想进哪个进哪个。后来他自己放
假回老家了,这时小女回来了(她也要用这些房间), 结果就是得一个个地擦掉小南刻的名字,升级为挂书
包的方式。老王(JVM)觉得这成本有点高,提出了一种批量重刻名的方法,他让小女不用挂书包了,可以直接在门
上刻上自己的名字.(这种情况当偏向锁被撤销达到一定的阈值的时候(20),jvm在这种情况就会认为,我不应该让这些对象专属让某个线程去使用了,也得让这些对象有机会被其它线程所使用,这种被称之为:批量重偏向)
后来,刻名的现象越来越频繁,老王受不了了:算了,这些房间都不能刻名了,只能挂书包.(当偏向锁撤销阈值达到4
的时候,jvm认为该类不适合做这些优化,即该类不适合被线程用来进行当前的工作,这就是将偏向锁设置为不可偏向)
防盗门:重量级锁
挂书包:轻量级锁
刻名字:偏向锁
6.2 轻量级锁
使用轻量级锁
的原因是提升效率
,synchronized重量级锁
每次每个对象都会和一个Monitor
关联,相率太慢。
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的( 也就是没有竞争,就是一个线程对一个对象加锁,解锁的这个过程中没有其它线程去获取该锁资源。等该线程将锁资源释放之后,另一个线程才去获取锁资源,对该对象进行加锁解锁),那么可以使用轻量级锁
来优化。这种是没有竞争的情况,如果有竞争则会升级成重量级锁。
轻量级锁对使用者是透明的,即语法仍然是synchronized
(它会先用轻量级锁,如果轻量级锁加锁失败,它才会用重量级锁)。
假设有两个方法同步块,利用同一个对象加锁:
下面我们来分析以下以上代码的加锁过程:
(1)在线程调用方法的时候都会创建一个栈帧。先在栈帧里创建锁记录
(Lock Record
)对象(JVM层面的,对于我们是不可见的),每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word
。
锁记录Lock Record
分为两部分:
lock record
地址:加锁对象的Mark word
信息Object reference
: 加锁对象的引用
(2)让锁记录中Object reference
指向锁对象Object
,并尝试用cas
替换Object
的Mark Word
,将Mark Word的值存入 锁记录(将object里的哈希值等和markword做一个交换)
(3)如果cas
替换成功,对象头中存储了锁记录地址和状态00
(表示加的是轻量级锁), 表示由该线程给对象加锁,而多对象里面就把对象的哈希码,分三年龄等存储在锁记录里(lock Record
),将来解锁的时候可以再给它恢复过去。这时图示如下
怎么知道加锁成功了呢,mark word
里有标记为,如下如所示:
此时轻量级锁的状态是由一开始是的01
变成00
,就是加锁成功。栈帧中的锁记录的lock record
的状态正好相反。
(4)如果cas
失败,有两种情况:
-
如果是其它线程已经持有了该
Object
的轻量级锁(对象头的锁码为00
),这时表明有竞争,进入锁膨胀
过程 -
如果是自己执行了
synchronized
锁重入,那么再添加一条Lock Record
作为重入的计数(锁重入自己又给自己加了一个锁,但是加锁的时候对象的状态已经是00
了,所以加锁失败,然后它知道这是自己给自加锁了,然后再添加一条Lock Record
作为重入的计数,然后新加的lock record
为null,它就是以lock Record
的个数来确定加了几次锁,有几个lock Record
就加了几次锁)
上面的4步为加锁,下面我们来看解锁的操作
(5)当退出synchronized
代码块(解锁时)如果有取值为nul
的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出
synchronized
代码块(解锁时)锁记录的值不为null
,这时使用cas
将Mark Word
的值恢复给对象头
,将哈希值
,和01
还原过去。(这个过程就是代码方法1执行完毕释放锁资源的场景)成功,则解锁成功;失败,说明轻量级锁进行了锁膨胀
或已经升级为重量级锁,进入重量级锁解锁流程。
6.3 膨胀锁
上面说了如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀即:将轻量级锁变为重量级锁。
(1)当Thread-1
进行轻量级加锁时,Thread-0
已经对该对象加了轻量级锁(看到锁对象的状态是00
)
这时Thread-1
加轻量级锁失败,进入锁膨胀流程:
- 即为
Object
对象申请Monitor
锁,让Object
指向重量级锁地址。(为啥申请Monitor
,因为线程1申请加锁,但是锁已经被其它线程使用,然后就加锁失败,然后就只能等待资源锁被释放了,所以要进入Monitor
锁去等待,就是阻塞状态,轻量级锁没有阻塞状态的说法,所以到重量级锁才有阻塞。申请Monitor
锁之后呢,Object
就不能再记录轻量级锁的信息了,它就指向了Monitor
(重量级锁的地址),锁码也会变成10
- 然后线程就进入
Monitor
的EntryList
BLOCKED
- 当
Thread-0
退出同步块解锁时,使用cas
将Mark Word
的值恢复给对象头,会失败(因为它在上一步就指向了重量级锁的地址而不是轻量级锁了,所以会解锁失败。这时会进入重量级解锁流程,即按照Monitor
地址找到Monitor
对象,设置Owner
为null,唤醒EntryList
中BLOCKED
线程。
七、自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化(其实就是在重量级锁竞争的时候发现monitor已经有owner的时候不会先进入阻塞而是进行几次循环重试去获取锁),如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以成为了owner就不用阻塞了。
所以当前线程就可以避免阻塞了。因为阻塞要发生一次线程的上下文切换,是比较慢,比较耗费性能的。如下图所示:
[在这里插入图片描述](https://img-blog.csdnimg.cn/72768ec4fe2e47dd928c6b7109b3b685.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MTIzMTkyO
自旋不成功,那就进入monitor锁,阻塞了:
优点:
避免阻塞,线程上下文切换的发生。
缺点:
它会使用cpu,多核的计算机比较好一点,如果你只有一核cpu,cpu正在执行线程1,它需要在线程1执行的同时去占用cpu去执行自旋,所以这种情况自选优化就没有啥意义了,所以自旋一定是多核cpu下才有意义。
即自旋的过程需要额外的cup去执行,单核CUP会来回切换上下文反而更影响效率。
但是在JDK 6之后自旋锁是自适应的
,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能任会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
什么时候会使用这个自旋优化呢?当一个线程去获取锁的时候发现monitor
已经拥有owner
了,另外的线程就会进入entryList
阻塞(重量级锁竞争的时候),就是此时进入了这个相关的优化。
八、偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS(简单的理解为是一系列的原子操作)操作。
Java 6中引入了偏向锁
来做进一步优化:只有第一次使用CAS
将线程ID
设置到对象的Mark Word
头,之后发现这个线程ID
是自己的就表示没有竞争,不用重新CAS
。以后只要不发生竞争,这个对象就归该线程所有。
即使是同一个线程重入每次还是要CAS替换检查,就行每次要翻找检查挂在门上的包:
偏向锁:不用翻书包了,把名字刻在门上,直接看名字就可以了
1.偏向状态
回忆一下对象头的Mark Word
格式,注意下图锁的状态以及对应的状态码,如Heavyweight Locked
(重量级锁)状态码: 10
一个对象创建时:
- 1、如果开启了偏向锁(默认开启),那么对象创建后,markword值为
OxO5
即最后3位为101,这时它的thread
、epoch
、age
都为0 - 2、偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数
–XX:BiasedLockingstartupDelay=0
来禁用延迟 - 3、如果没有开启偏向锁,那么对象创建后,
markword
值为Ox01
即最后3位为001
,这时它的hashcode
、age
都为0,第一次用到hashcode
时才会赋值。
测试偏向锁的延迟特性:
用代码验证一下开启了偏向锁对象的最后三位是否为001。
上面的markword表中发现001是正常状态。但是我们上面说道默认是开启了偏向锁的,按理来说这个对象是101而不是001呢?因为偏向锁默认是延迟的。
下面将线程睡眠4s,然后再创建一个对象:
刚一开始偏向锁没有生效所以是001,后面生效了所以是101。
每次都要让线程睡4s,比较麻烦:添加虚拟机参数:–XX:BiasedLockingstartupDelay=0
测试偏向锁
上面的101只是启用了偏向锁,但是还没有加锁。怎么加偏向锁呢?
代码里添加synchronized,它就会优先添加偏向锁而不是重量级锁或者轻量级锁:
打印结果分别为:
- 加锁前:101,表示启用了偏向锁。
- 加锁后:101,但是前面还多了一些东西,就是线程id(从右往左第10位(从1开始数)之后的就是线程id)根据对象头的定义,上面前54位是所属线程的 id。注意该id与java中线程getID的到的值是不一样的。这里的id是操作系统为线程设置的唯一标识。与java中的线程对象不是一一对应的。
- 解锁后(释放完锁):解锁后与加锁后没啥变化?这就是所谓的偏向,这里的dog对象以后就给这个线程用了(此处为main方法,所以线程为主线程)因为主线程一上来给它加了锁了,以后的d对象就从属与主线程了,它的
mark word
头里存储的始终就是主线程的线程id,除非其他线程使用了这个对象它才会改变或者其他的条件发生时它才会改变。这也是偏向锁的得名原因。
需要注意的是:处于偏向锁的对象锁解锁以后,线程Id仍存储在对象头中。
测试禁用偏向锁
偏向使用的场景是冲突很少的情况使用,比如我就一个线程去使用对象给对象加锁,这种场景比较适合使用偏向锁。但是如果程序里,一个对象的使用场景就是多线程它要去竞争去访问对象,这个时候偏向锁就不合适了,我们可以用一个参数把偏向锁给禁用掉,让它一上来就是正常状态(Normal),而不是Biased(可偏向的状态)
在上面测试代码运行时在添加VM参数 -xx:-UseBiasedLocking
禁用偏向锁(U前面的-代表禁用偏向锁,+表示启用偏向锁)。这行命令相当于是一个开关。
代码都没变只不过是在配置中把偏向锁给禁用了。
打印结果分别为:
- 加锁前:001,表示未启用偏向锁。
- 加锁后:000,后两位为00,是轻量级锁(看上面的mark word结构图),它前面的剩余62位就是轻量级锁的记录地址指针(看上面的mark word结构图)。重量级锁优化到轻量级锁,所以轻量级锁就不优化了,因为偏向锁被禁用了,仍然是轻量级锁。
- 解锁后(释放完锁):解锁后又恢复到001,未加锁的状态。
结论:
所以我们看到禁用了偏向锁,它上来用的直接就是轻量级锁。所以我们从优先级上划分:有偏向级锁,使用偏向级锁,如果其他线程使用了这个对象它就会撤销偏向锁,变成轻量级锁,如果轻量级锁有竞争,比如线程1加了轻量级锁,此时线程2来竞争了,这时候就会锁膨胀变成重量级锁。
所以优先顺序: 偏向锁> 轻量级锁>重量级锁
测试hashCode
首先确认我们的偏向锁是打开的。然后我们在使用偏向锁之前我们调用一下hashcode。这个操作很诡异,它会禁用到偏向锁。
打开偏向锁
打印结果分别为:
- 加锁前:按理来说应该是101(启用偏向锁,但是这里却是001即Normal正常状态,未启用偏向锁)
- 加锁后:加下来加的锁也不是偏向锁了而是:000,后两位为00,是轻量级锁(看上面的mark word结构图),即从正常状态变成了轻量级锁而非偏向锁。
- 解锁后(释放完锁):解锁后又恢复到001,未加锁的状态即正常状态。
上述结果的第一行从右往左(从1开始数),第9位开始就全都是hash码。这里的hash码呢,用的时候才会产生,其余情况都为0。当你第一次调用hashCode的时候它就会产生哈希码,并且将哈希码填充到对象头的mark word里面。那为什么调用一下哈希码就会禁用到对象的偏向锁呢?因为如果对象处于偏向锁的状态那么mark word最多存储一个偏向锁的线程id如下图。在想存31位的hashcode就存不下了。所以当一个可偏向的对象调用它的hashCode方法以后,它就会撤销这个对象的偏向状态(状态码由101变成001)然后把的线程ID啥的都给清掉,然后将hashCode存储到对象头的mark word里面,因为它没地方存储哈希码。
所以我们要记住,调用hashCode之后它就会由偏向状态变为正常状态。
那为什么我们的轻量级锁、重量级锁调用hashCode之后不会有这个问题呢?
是因为轻量级锁的hashCode会存储在线程栈帧的锁记录里。
重量级锁的哈希码会存储在monitor对象里。
轻量级锁和重量级锁将来解锁的时候还会把哈希码还原回来。但是偏向锁它没有额外的存储空间了,所以调用hashCode之后会让偏向状态禁用,从而变成不可偏向的正常状态。
九、撤销偏向锁
有3种方法:
- 调用该对象的hashCode方法(见上小节)
- 其它线程使用对象
- 调用wait/notify
9.1 其它线程使用对象
当有其它线程使用偏向锁时,会将偏向锁升级为轻量级锁,于是偏向锁也就撤销了。
场景:
如果线程1已经给对象加锁了,相当于该对象是偏向于线程1的,但是后来呢有一个其它线程也想用这个对象,那就跟偏向的本意就违背了,因为本来是说这个对象是线程1专用的,结果又来个线程2要使用这个对象,此时它也会撤销对象的偏向状态,将对象的状态从可偏向变成不可偏向,最后偏向锁也会升级为轻量级锁。
要演示效果,必须得让两个线程交错开,必须是线程1把锁解开了,线程2再去加锁,如果没有交错就不是轻量级锁了,那就是重量级锁了。偏向锁和轻量级锁有个前提就是访问对象的时候,两个线程是错开的。
如下代码:一上来2个线程都运行,但是t2线程是陷入等待。因为t1线程线运行,此时测试类的类对象已经被加锁,t2线程陷入等待队列,然后t1线程执行完毕时候再通知这个测试类的类对象上的等待线程,将他门唤醒,因为这里只有一个t2线程等待所以我们用了notify,用notifyAll也是可以的。所以此时t1与t2线程就是错开的。
注意:这里不锁d对象,它是我们的测试对象,我们锁这个测试类的类对象。
运行结果:
线程1对d对象加的是偏向锁
线程2加的是轻量级锁。
验证本节开头的结论:即当有其它线程使用偏向锁时,会将偏向锁升级为轻量级锁。
9.2 调用wait/notify
为什么调用wati/notify之后会撤销偏向锁呢?因为wait和notify只有重量级锁才有,所以当用到wait与notify这种机制的时候只有重量锁才有,所以调用以上两个方法的时候它肯定会将轻量级锁或者偏向锁升级为重量级锁。
十、批量重偏向 && 批量撤销
可偏向的对象被两个线程访问或被多个线程访问时,就会导致它的偏向状态被改变,即从可偏向变为不可偏向。其实这就叫做撤销偏向。
撤销偏向对性能的损耗有些大。
批量重偏向基于这么一个场景进行优化:
如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的Thread ID
当撤销偏向锁阈值超过20次后(可简单理解为一只从t1偏向至t2,偏向了很多次),jvm会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。而不是撤销然后升级为轻量级锁,因为撤销也是耗费性能的。而且是批量的把这些对象重偏向到其他线程。
如下代码:
第一个for循环中加锁是为了让这30个对象都先偏向t1,接着线程2就开始运行了,线程2会一开始在list对象上等待。等到线程1把这30个对象加锁加完了,线程1就会notify唤醒list,然后notify就会让我们的县城向下运行,线程2要再对这30个对象加锁。当线程2里的for循环第一次给对象加锁的时候,因为list里的对象原先都是偏向t1,当线程2第一次将他加锁的时候,它会将偏向锁撤销掉升级为轻量级锁。但是这样的循环次数比较多了,也就是撤销偏向锁的次数比较多的时候,超过了一个阈值(默认是20),jvm就会认为它对象偏向给t1偏向错了,jvm就会把剩下的对象重新批量的偏向给t2。
控制台打印前30条为t1线程:给这30个对象加偏向锁(后3位为101)到t1
t2线程的结果前18条都是对象被撤销然后添加上轻量级锁。
但是t2从第20(打印索引从第0个开始打印,所以第19个索引对对应的就是第20个对象)个对象开始又变成101了,也就是加了偏向锁。
但是这个锁它加在哪个线程上了呢?我们可以对比一下线程id,后3位是锁的状态码,其余的就是线程的id。我们拿线程1的打印结果的县城id与线程2的打印结果的线程id对比发现是不同的。所以批量重偏向,不是撤销然后升级为轻量级锁,因为撤销也是耗费性能的。而且是批量的把这些对象重偏向到其他线程。
当撤销偏向锁阈值超过40次后,jvm 会这样觉得,这个类的竞争非常的激烈,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。
例如上边的例子,我们在线程2执行完之后再来个线程3,于是到第40次撤销偏向锁之后,该对象就不可偏向了。
十一、锁消除
上面a()、b()两个方法耗时差不多,为什么加锁了也不会影响效率?
因为JIT即时编译器
会对代码进行分析,这里第二个b方法虽然加了锁但是因为是局部变量,不可能在线程间共享,那加锁就没有意义,JIT
就会把加锁撤销掉,真正执行时就不会加锁,这就是锁消除。
但是锁消除是有参数可以控制的:-XX:-EliminateLocks
十二、wait/notify原理
小故事,为什么需要wait?
拥有锁资源,但是又不满足执行条件的线程,就可以调用wait
方法去等待。
- 结论1:线程调用
wait
会释放当前线程所拥有的锁资源。 - 结论2:当其他线程执行
notify/notifyAll
方法后wait
中的线程才会被唤醒,才有资格去重新竞争资源锁。
12.1 wait/notify原理
Owner
线程发现条件不满足,调用wait
方法,即可进入WaitSet
变WAITING
状态。BLOCKED
和WAITING
的线程都处于阻塞状态,不占用CPU
时间片。BLOCKED
线程会在Owner
线程释放锁时唤醒。WAITING
线程会在Owner
线程调用notify
或notifyAll
时唤醒,但唤醒后并不意味者立刻获得锁仍需进入EntryList
重新竞争。
注意:
- waiting是无限的等待,直到被唤醒。而timewait是有时限的等待。
- wait:已经获得过锁(已经是owner了)但是又放弃了锁,在Monitor的WaitSet等待的线程。
- blocked:还没有获得过锁资源并且正在等待锁的线程。这些线程在Monitor的EntryList中等待。
- WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味者立刻获得锁仍需进入EntryList重新竞争。
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片,cpu在调度的时候也不会考虑它们。
12.2 wait/notify (API)
wait、notify、notifyAll方法是Object类中的方法。
- obj .wait()让进入object监视器(其实就是获取到锁资源的线程)的线程到waitSet等待。
- obj.notify()在object.上正在waitSet等待的线程中挑一个唤醒。
- obj.notifyAll()让object. 上正在waitSet等待的线程全部唤醒。
它们都是线程之间进行协作的手段,都属于Object对象的方法。
不管是wait还是notify/notifyAll,他们都有一个前提就是线程都必须获得了对象锁。成为了owner之后才能调用这3个方法。例如成为owner之后你可以自己调用wait让自己等待,或者调用notify去唤醒其他的wait状态下的线程。
测试代码 - 1:
以上异常是为什么呢?因为你这个线程(main主线程),它根本就没有获得锁,也就无法将线程转为wait状态。
所以我们的修改一下上面的代码,先让线程获得锁,然后再将它转为wait状态。我们先synchronized
的让线程获得该对象的对象锁,成为了owner
,然后再调用对象锁。就进入了lock
所关联的Monitor
对象的waitSet
里去等待。
代码为什么没有运行结束呢?因为主线程已经进入了wait
状态了,它再等,等其它拥有线程锁的线程唤醒它。唤醒后也不会立刻去执行,而是重新去竞争资源锁。
同理notify/nofifyAll
也是同样的情况,报的错误也是一样的。
测试代码 - 2:
notify与notifyAll的区别
public class WaitNotifyTest01 {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj) {
System.out.println("执行了线程1");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1任务执行完毕");
}
}
},"t1").start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj) {
System.out.println("执行了线程2");
try {
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程2执行完毕");
}
}
},"t2").start();
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println("主线程唤醒 t1 或 t2 线程....");
synchronized (obj){
obj.notify();
}
}
}
执行了线程1
执行了线程2
主线程唤醒 t1 (或) t2 线程....
线程1任务执行完毕
主线程开启了t1 和 t2 两个线程,执行后t1和t2先后wait进入obj的Monotor对象的waitset集合。
主线程获得锁之后,调用notify唤醒t1和t2中的一个。
上面可以看到只唤醒了t1,其实notify是随机唤醒,也可能是唤醒t2,这是因为t1和t2在主线程notify后是随机叫醒的。
下面改成notifyAll:
TimeUnit.MILLISECONDS.sleep(1000);
System.out.println("主线程唤醒 t1 和 t2 线程....");
synchronized (obj){
obj.notifyAll();
}
执行了线程1
执行了线程2
主线程唤醒 t1 和 t2 线程....
线程2任务执行完毕
线程1任务执行完毕
可以看到
- 唤醒的是所有线程
- 但是t2和t1的执行顺序也是随机的,这是因为主线程调用
notifyAll
,使得t1和t2线程由waitset
集合转到EntryList
集合,EntryList
集合里面的线程获取锁是非公平的,不是先到先得。
测试代码 - 3:
带时间的wait方法
wait无参方法:
无参的wait方法实际上是调用了一个参数为0的wait方法。这里的0的含义就是无限制的等待下去。方法上也有描述,其它线程使用notify/notifyAll方法去唤醒它。
wait的有时限方法
wiat有时限的方法,等待指定的时间。如果在指定的时间之后如果没有线程把它唤醒,它就会自动结束等待。然后继续向下执行。如果在等待的过程中它被其它线程唤醒了,那就不会再等到设定的时间再醒。
wait有两个参数的时限方法
其实就是纳秒的范围,如果不再范围则抛出异常,否则就把秒数+1s,用的还是有时限的wait(timeout)方法。
例子:
public class WaitNotifyTest03 {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj) {
System.out.println(DateUtil.now() + "执行了线程1");
try {
obj.wait(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(DateUtil.now() + "线程1任务执行完毕");
}
}
},"t1").start();
System.out.println(DateUtil.now() + "主线程....");
}
}
2021-08-02 11:20:29执行了线程1
2021-08-02 11:20:29主线程....
2021-08-02 11:20:30线程1任务执行完毕
可以看到没有线程去notify,但是1秒后t1线程自己就醒了,继续执行任务。
中途有别的线程唤醒了,那就不会再等到设定的时间再醒:
public class WaitNotifyTest03 {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (obj) {
System.out.println(DateUtil.now() + "执行了线程1");
try {
obj.wait(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(DateUtil.now() + "线程1任务执行完毕");
}
}
},"t1").start();
System.out.println(DateUtil.now() + "主线程....");
TimeUnit.MILLISECONDS.sleep(1000);
synchronized (obj){
System.out.println(DateUtil.now() + "主线程中途唤醒t1....");
obj.notifyAll();
}
}
}
2021-08-02 11:22:55主线程....
2021-08-02 11:22:55执行了线程1
2021-08-02 11:22:56主线程中途唤醒t1....
2021-08-02 11:22:56线程1任务执行完毕
12.3 wait vs sleep
- sleep是 Thread方法,而wait是 Object的方法
- sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用(例如线程如果没有获得锁就调用wait会抛出异常的)
- sleep在睡眠的同时,不会释放对象锁的,但wait在等待的时候会释放对象锁。(也就是说如果一个线程获得了锁,并且sleep了,且sleep的时间没到,那么其他线程是不会获的锁的。但是wait就不是,线程一点wait了,那么它会释放它所占有的锁资源,当它再wait状态的时候,其它线程可以得到锁)。
- 共同点:他们的状态都是一样的都是:TIMED_WAITING(有时限的等待)
如下代码,主线程在20s内是得不到锁的:
如下代码,主线程是可能会获取到锁的
12.4 wait/notify正确使用姿势
背景:一些线程要使用一个共享的房间来达到一个线程安全的目的。所以都要使用加锁的方式去进入到room房间去做些线程安全的代码。
public class WaitTest01 {
static final Object room = new Object();
static boolean hasCigarette = false;// 有没有烟
static boolean hasTakeout = false;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(DateUtil.now() + "有烟没? " + hasCigarette);
if (!hasCigarette) {
Console.log(DateUtil.now() + "没烟先歇会: " + hasCigarette);
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Console.log(DateUtil.now() + "有烟没? " + hasCigarette);
if (hasCigarette) {
Console.log(DateUtil.now() + Thread.currentThread().getName() + ":可以干活了: " + hasCigarette);
}
}
}
}, "小南").start();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(DateUtil.now() + Thread.currentThread().getName() +":可以干活了 " + hasCigarette);
}
}
},"其他人").start();
}
Thread.sleep(1000);
new Thread(new Runnable() {
@Override
public void run() {
// 下面的代码可以加锁吗?
hasCigarette = true;
Console.log(DateUtil.now() + Thread.currentThread().getName() +":烟到了 " + hasCigarette);
}
},"送烟的").start();
}
}
2021-08-02 11:48:17有烟没? false
2021-08-02 11:48:17没烟先歇会: false
2021-08-02 11:48:18送烟的:烟到了 true
2021-08-02 11:48:23有烟没? true
2021-08-02 11:48:23小南:可以干活了: true
2021-08-02 11:48:23其他人:可以干活了 true
2021-08-02 11:48:23其他人:可以干活了 true
2021-08-02 11:48:23其他人:可以干活了 true
2021-08-02 11:48:23其他人:可以干活了 true
2021-08-02 11:48:23其他人:可以干活了 true
以上一共7个线程,其中当没有烟的时候小南用的是sleep方法,该方法不会释放锁资源,所以当小南没有烟的时候其他线程也都无法运行。其次,因为锁在小南手里,只有当小南将代码执行完毕,将所资源释放后,其他线程才能去得到锁,于是就是上面的打印输出结果。
以上代码问题1:送烟的线程虽然在1s之后就把烟送到了,但是小南还是睡足了6s才醒过来。(优化方法,可以使用interrupt把睡眠中的线程叫醒,只要小南的这个线程捕捉到异常它就可以继续向下执行)
更严重的问题2:在小南睡觉的时候其他线程并没有干活,因为小南锁住了房间,并且sleep,没有释放资源。所以其他线程只能等待小南干完了,他们再继续执行。显然这样的效率非常的低。
严重的问题3:送烟的线程可以加synchronized吗?加了synchronized (room)后,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main没加synchronized就好像main线程是翻窗户进来的。所以送烟的线程不能加synchronized。
如果送烟的线程也加了synchronized,相当于小南根本就没有干活。
使用wait-notify机制解决上面的问题:
public class WaitTest02 {
static final Object room = new Object();
static boolean hasCigarette = false;// 有没有烟
static boolean hasTakeout = false;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log("有烟没? " + hasCigarette);
if (!hasCigarette) {
Console.log("没烟先歇会: " + hasCigarette);
try {
room.wait(2000);
} catch (InterruptedException e) {
// 该异常什么时候会抛出呢?就是其他线程调用interrupt方法
// 它也会让正在wait的线程被打断,打断之后,我们这边接到异常
// 就知道该线程被打断了,也可以对打断标记进行判断,于是可以继续进行处理。
// 当然了,更合理的方法是其他线程调用notify方法来唤醒
// 正在wait的线程
e.printStackTrace();
}
}
Console.log("有烟没? " + hasCigarette);
if (hasCigarette) {
Console.log(Thread.currentThread().getName() + ":可以干活了: " + hasCigarette);
}
}
}
}, "小南").start();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() +":可以干活了 " + hasCigarette);
}
}
},"其他人").start();
}
Thread.sleep(1000);
new Thread(new Runnable() {
@Override
public void run() {
// 下面的代码可以加锁吗?
synchronized (room) {
hasCigarette = true;
Console.log(Thread.currentThread().getName() +":烟到了 " + hasCigarette);
// 这里要要注意,
// notify/notifyAll只能唤醒wait状态下的线程
// 对blocked状态下的线程毫无作用。
// 所以不管是wait还是notify/notifyAll,
// 他们都有一个前提就是线程都必须获得了对象锁。成为了owner之后才能调用这3个方法
// 所以这里要在synchronized代码块里调用notify,否则将抛出非法状态异常
room.notify();
}
}
},"送烟的").start();
}
}
有烟没? false
没烟先歇会: false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
送烟的:烟到了 true
有烟没? true
小南:可以干活了: true
结果分析:小南得到锁,但是没有烟,于是调用wait方法(该方法会释放锁资源)所以此时小南休息不会影响其他线程干活。于是其他线程开始竞争并获得锁资源,其它线程没有烟也可以运行。于是其他线程运行完了,然后小南直到等到烟到了,然后被送烟的唤醒notify,然后,小南重新竞争资源锁,然后获取到锁之后开始从它的room.wait代码之后开始运行。
分析:
进步1:可以让其他线程同时运行,小南线程不会占用锁,并发的效率得到了提升。
问题1:如果小南等待的同时也有其他的线程在等待,那送烟的线程在调用notify的时候会不会错误的调用其他线程呢?下面我们继续来优化。
public class WaitTest03 {
static final Object room = new Object();
static boolean hasCigarette = false;// 有没有烟
static boolean hasTakeout = false; // 外卖
// 虚假唤醒
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() + ":有烟没? " + hasCigarette);
if (!hasCigarette) {
Console.log(Thread.currentThread().getName() + ":没烟先歇会: " + hasCigarette);
try {
room.wait();
} catch (InterruptedException e) {
// 该异常什么时候会抛出呢?就是其他线程调用interrupt方法
// 它也会让正在wait的线程被打断,打断之后,我们这边接到异常
// 就知道该线程被打断了,也可以对打断标记进行判断,于是可以继续进行处理。
// 当然了,更合理的方法是其他线程调用notify方法来唤醒
// 正在wait的线程
e.printStackTrace();
}
}
Console.log(Thread.currentThread().getName() + ":有烟没? " + hasCigarette);
if (hasCigarette) {
Console.log(Thread.currentThread().getName() + ":可以干活了: " + hasCigarette);
} else {
Console.log(Thread.currentThread().getName() + ":没干成活: " + hasCigarette);
}
}
}
}, "小南").start();
// 该线程与小南线程几乎一样,但是它等的是外卖而不是烟
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() + ":外面送到没? " + hasTakeout);
if (!hasTakeout) {
Console.log(Thread.currentThread().getName() + ":没等待外卖先歇会: " + hasTakeout);
try {
room.wait();
} catch (InterruptedException e) {
// 该异常什么时候会抛出呢?就是其他线程调用interrupt方法
// 它也会让正在wait的线程被打断,打断之后,我们这边接到异常
// 就知道该线程被打断了,也可以对打断标记进行判断,于是可以继续进行处理。
// 当然了,更合理的方法是其他线程调用notify方法来唤醒
// 正在wait的线程
e.printStackTrace();
}
}
Console.log(Thread.currentThread().getName() + ":外面送到没? " + hasTakeout);
if (hasTakeout) {
Console.log(Thread.currentThread().getName() + ":可以干饭了: " + hasTakeout);
} else {
Console.log(Thread.currentThread().getName() + ":没干成饭: " + hasTakeout);
}
}
}
}, "小女").start();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() +":可以干活了 " + hasCigarette);
}
}
},"其他人").start();
}
Thread.sleep(1000);
// 该送外卖的线程到底将谁叫醒了呢?
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
hasTakeout = true;
Console.log(Thread.currentThread().getName() +":外卖到了 " + hasTakeout);
// 这里要要注意,
// notify/notifyAll只能唤醒wait状态下的线程
// 对blocked状态下的线程毫无作用。
// 所以不管是wait还是notify/notifyAll,
// 他们都有一个前提就是线程都必须获得了对象锁。成为了owner之后才能调用这3个方法
// 所以这里要在synchronized代码块里调用notify,否则将抛出非法状态异常
room.notify();
}
}
},"送外卖的").start();
}
}
小南:有烟没? false
小南:没烟先歇会: false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
小女:外面送到没? false
小女:没等待外卖先歇会: false
送外卖的:外卖到了 true
小南:有烟没? false
小南:没干成活: false
结果分析:小南,小女都没有满足条件吃或者吸烟,于是他们就都wait歇会,此时锁被释放,于是其他线程可以竞争锁,并且运行。
注意notify它只能随机的唤醒所有处于wait等待的一个线程。以上结果表明它随机的唤醒了小南,于是小南就从自己的room.wait之后的代码开始运行,但是它是吧外卖的标记置为true了,于是小南仍旧不满足干活条件。于是小南没有干成活,一直在room的wait中摸鱼。所以小南是被错误唤醒了,我们也称之为**虚假唤醒**。
解决方案:
以上代码中使用notifyAll来代替notify,它会唤醒所有正在等待的线程(前提是同一个room里,也就是同一把对象锁)。如下代码,小南、小女都被唤醒,小女可以干饭,小南因没有烟,没有干成活(说明被唤醒了)
小南:有烟没? false
小南:没烟先歇会: false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
小女:外面送到没? false
小女:没等待外卖先歇会: false
送外卖的:外卖到了 true
小女:外面送到没? true
小女:可以干饭了: true
小南:有烟没? false
小南:没干成活: false
结论:我们使用notifyAll去代替notify可以解决一部分线程虚假问题。
问题:小女是干成饭了,但是小南还是没有干成活,并且被唤醒了还,也就是说仍然有线程被唤醒了,但是又不满足线程的运行条件执行不了正确的逻辑。
代码改进(while代替if):
public class WaitTest04 {
static final Object room = new Object();
static boolean hasCigarette = false;// 有没有烟
static boolean hasTakeout = false; // 外卖
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() + ":有烟没? " + hasCigarette);
while (!hasCigarette) {
Console.log(Thread.currentThread().getName() + ":没烟先歇会: " + hasCigarette);
try {
room.wait();
} catch (InterruptedException e) {
// 该异常什么时候会抛出呢?就是其他线程调用interrupt方法
// 它也会让正在wait的线程被打断,打断之后,我们这边接到异常
// 就知道该线程被打断了,也可以对打断标记进行判断,于是可以继续进行处理。
// 当然了,更合理的方法是其他线程调用notify方法来唤醒
// 正在wait的线程
e.printStackTrace();
}
}
Console.log(Thread.currentThread().getName() + ":有烟没? " + hasCigarette);
if (hasCigarette) {
Console.log(Thread.currentThread().getName() + ":可以干活了: " + hasCigarette);
} else {
Console.log(Thread.currentThread().getName() + ":没干成活: " + hasCigarette);
}
}
}
}, "小南").start();
// 该线程与小南线程几乎一样,但是它等的是外卖而不是烟
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() + ":外面送到没? " + hasTakeout);
while (!hasTakeout) {
Console.log(Thread.currentThread().getName() + ":没等待外卖先歇会: " + hasTakeout);
try {
room.wait();
} catch (InterruptedException e) {
// 该异常什么时候会抛出呢?就是其他线程调用interrupt方法
// 它也会让正在wait的线程被打断,打断之后,我们这边接到异常
// 就知道该线程被打断了,也可以对打断标记进行判断,于是可以继续进行处理。
// 当然了,更合理的方法是其他线程调用notify方法来唤醒
// 正在wait的线程
e.printStackTrace();
}
}
Console.log(Thread.currentThread().getName() + ":外面送到没? " + hasTakeout);
if (hasTakeout) {
Console.log(Thread.currentThread().getName() + ":可以干饭了: " + hasTakeout);
} else {
Console.log(Thread.currentThread().getName() + ":没干成饭: " + hasTakeout);
}
}
}
}, "小女").start();
for (int i = 0; i < 5; i++) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
Console.log(Thread.currentThread().getName() +":可以干活了 " + hasCigarette);
}
}
},"其他人").start();
}
Thread.sleep(1000);
// 该送外卖的线程到底将谁叫醒了呢?
new Thread(new Runnable() {
@Override
public void run() {
synchronized (room) {
hasTakeout = true;
Console.log(Thread.currentThread().getName() +":外卖到了 " + hasTakeout);
// 这里要要注意,
// notify/notifyAll只能唤醒wait状态下的线程
// 对blocked状态下的线程毫无作用。
// 所以不管是wait还是notify/notifyAll,
// 他们都有一个前提就是线程都必须获得了对象锁。成为了owner之后才能调用这3个方法
// 所以这里要在synchronized代码块里调用notify,否则将抛出非法状态异常
//room.notify();
room.notifyAll();
}
}
},"送外卖的").start();
}
}
小南:有烟没? false
小南:没烟先歇会: false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
其他人:可以干活了 false
小女:外面送到没? false
小女:没等待外卖先歇会: false
送外卖的:外卖到了 true
小女:外面送到没? true
小女:可以干饭了: true
小南:没烟先歇会: false
结果分析:
我们使用while去代替if,好处在于,判断有没有烟的逻辑是循环的,它第一次不满足条件,等到再次被唤醒的时候,root.wait执行下面的代码,然后下面没代码,它就又回到了while,去判断有没有烟,小南被喊醒,一看是送外卖的于是再次沉睡(送外卖的是把hasTakeout = true;设置为true,此时小南还是没有烟hasCigarette仍然为false),没有烟于是就再次wait,然后不会执行那句没有干成活的错误逻辑代码,因为我们是想让它干成活的。从而真正解决线程被虚假唤醒后做一些无用工。
wait/notify/notifyAll的正确使用姿势
通过以上5步我们总结出wait/notify/notifyAll的正确使用姿势:
当线程使用wait的时候,建议用while判断如果条件不成立继续等待,然后其他线程建议使用notifyAll去唤醒正待中的线程们。
补充一、静态方法和非静态方法区别
1.普通方法和普通代码块锁住的是当前实例对象,同个对象调用是同步效果
2.静态方法和以synchronized(class){}的方式锁代码块,锁住的是当前类的class对象,在同个类内,所属线程独占类锁,其他线程阻塞。
补充二、CopyOnWriteArrayList
一般认为:CopyOnWriteArrayLis
是同步List
的替代品,CopyOnWriteArraySet
是同步Set
的替代品。属于免锁容器。
无论是Hashtable-->ConcurrentHashMap
,还是说Vector-->CopyOnWriteArrayList
。JUC下支持并发的容器与老一代的线程安全类相比,总结起来就是加锁粒度的问题。
- 老一代
Hashtable
、Vector
安全集合加锁的粒度大(直接在方法声明处使用synchronized) - JUC中的
ConcurrentHashMap
、CopyOnWriteArrayList
加锁粒度小(用各种的方式来实现线程安全,比如我们知道的ConcurrentHashMap用了cas锁、volatile等方式来实现线程安全…) - JUC下的线程安全容器在遍历的时候不会抛出ConcurrentModificationException异常
CopyOnWriteArrayList实现原理
底层通过复制数组的方式来实现,遍历的时候就不用额外加锁。
CopyOnWriteArrayList
基本的结构:
/** 可重入锁对象 */
final transient ReentrantLock lock = new ReentrantLock();
/** CopyOnWriteArrayList底层由数组实现,volatile修饰 */
private transient volatile Object[] array;
/**
* 得到数组
*/
final Object[] getArray() {
return array;
}
/**
* 设置数组
*/
final void setArray(Object[] a) {
array = a;
}
/**
* 初始化CopyOnWriteArrayList相当于初始化数组
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
CopyOnWriteArrayList
底层就是数组,加锁就交由ReentrantLock
来完成。
CopyOnWriteArrayList
使用迭代器遍历时不需要显示加锁,看看add()、clear()、remove()与get()
方法的实现可能就有点眉目了。
public boolean add(E e) {
// 加锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 得到原数组的长度和元素
Object[] elements = getArray();
int len = elements.length;
// 复制出一个新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 添加时,将新元素添加到新数组中
newElements[len] = e;
// 将volatile Object[] array 的指向替换成新数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
通过代码我们可以知道:在添加的时候就上锁,并复制一个新数组,增加操作在新数组上完成,将array指向到新数组中,最后解锁。
再来看看size()方法:
public int size() {
// 直接得到array数组的长度
return getArray().length;
}
再来看看get()方法:
public E get(int index) {
return get(getArray(), index);
}
final Object[] getArray() {
return array;
}
那再来看看set()方法
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 得到原数组的旧值
Object[] elements = getArray();
E oldValue = get(elements, index);
// 判断新值和旧值是否相等
if (oldValue != element) {
// 复制新数组,新值在新数组中完成
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
// 将array引用指向新数组
setArray(newElements);
} else {
// Not quite a no-op; enssures volatile write semantics
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
对于remove()、clear()跟set()和add()是类似的,这里我就不再贴出代码了。
总结:
- 在修改时,复制出一个新数组,修改的操作在新数组中完成,最后将新数组交由array变量指向。就是说写入时将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
- 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的
情况下,可能导致young gc
或者full gc
; - 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个
set
操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList
能做到最终一致
性,但是还是没法满足实时性要求;
CopyOnWriteArrayList 透露的思想
1、读写分离,读和写分开
2、最终一致性
3、使用另外开辟空间的思路,来解决并发冲突
CopyOnWriteSet
CopyOnWriteArraySet
的原理就是CopyOnWriteArrayList
。
private final CopyOnWriteArrayList<E> al;
public CopyOnWriteArraySet() {
al = new CopyOnWriteArrayList<E>();
}