synchronized 关键字 - 监视器锁 monitor lock
- 5. synchronized 关键字 - 监视器锁 monitor lock
- 5.1 synchronized 的特性
- 5.2 synchronized 使⽤⽰例
- 5.3 Java 标准库中的线程安全类
本节⽬标
• 掌握 synchronized关键字
5. synchronized 关键字 - 监视器锁 monitor lock
(JVM 中采用的一个术语。使用锁的过程中抛出一些异常,可能会看到 监视器锁 这样的报错信息)
5.1 synchronized 的特性
(1) 互斥
synchronized 会起到互斥效果, 某个线程执⾏到某个对象的 synchronized 中时, 其他线程如果也执⾏到同⼀个对象 synchronized 就会阻塞等待.
• 进⼊ synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁
synchronized用的锁是存在Java对象头⾥的。
可以粗略理解成, 每个对象在内存中存储的时候, 都存有⼀块内存表⽰当前的 “锁定” 状态(类似于厕所的 “有⼈/⽆⼈”).
如果当前是 “⽆⼈” 状态, 那么就可以使⽤, 使⽤时需要设为 “有⼈” 状态.
如果当前是 “有⼈” 状态, 那么其他⼈⽆法使⽤, 只能排队
理解 “阻塞等待”.
针对每⼀把锁, 操作系统内部都维护了⼀个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进⾏加锁, 就加不上了, 就会阻塞等待, ⼀直等到之前的线程解锁之后, 由操作系统唤醒⼀个新的线程,再来获取到这个锁.
注意:
• 上⼀个线程解锁之后, 下⼀个线程并不是⽴即就能获取到锁. ⽽是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的⼀部分⼯作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C都在阻塞队列中排队等待。 但是当 A 释放锁之后, 虽然 B ⽐ C 先来的, 但是 B 不⼀定就能获取到锁,⽽是和 C 重新竞争, 并不遵守先来后到的规则.
锁, 本质上也是操作系统提供的功能,内核提供的功能 =>通过 api 给应用程序了。java (VM)对于这样的系统 api 又进行了封装.
synchronized 是调用 系统的 api 进行加锁。系统 api 本质上是靠 cpu 上的特定指令完成加锁
(2)可重⼊
synchronized 加锁的效果,也可以称为"互斥性。synchronized 还有一些其他特性:
理解 “把⾃⼰锁死”
⼀个线程没有释放锁, 然后⼜尝试再次加锁.
// 第⼀次加锁, 加锁成功
lock();
// 第⼆次加锁, 锁已经被占⽤, 阻塞等待.
lock();
按照之前对于锁的设定, 第⼆次加锁的时候, 就会阻塞等待. 直到第⼀次的锁被释放, 才能获取到第⼆个锁. 但是释放第⼀个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想⼲了, 也就⽆法进⾏解锁操作. 这时候就会 死锁.
这样的锁称为 不可重⼊锁
for (int i = 0; i < 50000; i++) {synchronized (locker) {synchronized (locker) {count++;}}
}
看起来是两次一样的加锁,没有必要。但是实际上开发中,很容易写出这样的代码的。
一旦方法调用的层次比较深,就搞不好容易出现这样的情况
要想解除阻塞,需要往下执行才可以,要想往下执行就需要等到第一次的锁被释放,这样的问题,就称为"死锁”。
这样的代码在 Java 中其实是不会死锁的!!! 为了避免程序猿粗心大意搞出死锁!java引入了"可重入机制",Java 中的 synchronized 是 可重⼊锁, 因此没有上⾯的问题。
最外层“ { ”真正加锁
最外层“ }” 真正解锁
站在 JVM 的视角,看到多个}需要执行,JVM 如何知道哪个}是真正解锁的那个??
先引入一个变量,计数器(0),每次触发{的时候,把计数器++,每次触发 } 的时候,把计数器 - -,当计数器 - - 为 0 的时候, 就是真正需要解锁的时候~
在可重⼊锁的内部, 包含了 “线程持有者” 和 “计数器” 两个信息:(1)如果某个线程加锁的时候, 发现锁已经被⼈占⽤, 但是恰好占⽤的正是⾃⼰, 那么仍然可以继续获取到锁, 并让计数器⾃增.(2)解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)
死锁是面试中考察的重点,也是工作中,多线程开发中非常核心的注意事项~~
若面试官的问题:
如何自己实现一个可重入锁?
1.在锁内部记录当前是哪个线程持有的锁,后续每次加锁,都进行判定
2.通过计数器,记录当前加锁的次数,从而确定何时真正进行解锁,
死锁(死锁的进一步讨论)
“死锁”是多线程代码中的一类经典问题, 加锁是能解决线程安全问题,但是如果加锁方式不当,就可能产生死锁!!
死锁同样也是经典面试题!!
死锁的三种典型场景:
场景1. 一个线程, 一把锁.
刚才说情况 如果锁是不可重入锁,并且一个线程针对一个锁对象,连续加锁两次,就会出现死锁(钥匙锁屋里了)
通过引入可重入锁,问题就迎刃而解了
场景2. 两个线程,两把锁
两个线程,两把锁,每个线程获取到一把锁之后,尝试获取对方的锁。线程1 获取到 锁A,线程2 获取到 锁B,接下来1 尝试获取 B,2 尝试获取 A,就同样出现死锁了!!!(屋钥匙锁车里了,车钥匙锁屋里了)
如果不加 sleep, 很可能 t1 一口气就把 locker1 和 locker2 都拿到了.这个时候,t2 还没开动呢~ 自然无法构成死锁.
经典面试题:让你手写一个出现死锁的代码:
C++方向,代码就好写,直接加锁两次就行了
Java 方向,就得通过上述代码,两个线程两把锁,精确控制好加锁的顺序
这里也就需要让我们知道,如果遇到死锁问题,就可以通过上述调用栈+状态进行定位了
场景3. N 个线程 M 把锁
一个经典的模型,哲学家就餐问题(学校的操作系统课上,也会有这个东西)
死锁,非常严重的问题~~ 属于程序中最严重的一类 bug !!!
一旦出现死锁,线程就"卡住了"无法继续工作,一个进程中的线程个数,就那么多。更可怕的是,死锁这种bug, 往往都是概率 出现,测试的时候怎么测试都没事,一发布就出问题,发布了也没问题,等到夜深人静,大家都睡着,突然给你整出点问题!比 bug 更可怕的是,“概率性出现的 bug”。虽然概率小,但是我们也需要重视!! 假设上述问题的 概率是 万分之一,同样是需要我们处理的,当时阿里这边的服务器每天的访问量是 3亿次,每天就有 3万个用户,触发了这个 bug!
如何避免死锁问题?
教科书上经典的,死锁的四个必要条件 !!!(下列四个条件,要求大家背下来!!面试经典问题!!)必要条件: 缺一不可!任何一个死锁的场景,都必须同时具备上述四点,只要缺少一个,都不会构成死锁。
1.锁具有互斥特性.
一个线程拿到锁之后,其他线程就得阻塞等待(锁最基本的特性.,不太好破坏)
2.锁不可抢占(不可被剥夺)
一个线程拿到锁之后,除非他自己主动释放锁,否则别人抢不走~~(也是锁最基本的特性.,也不好破坏)
3.请求和保持
一个线程拿到一把锁之后,不释放这个锁的前提下,再尝试获取其他锁。(如果先放下左手的筷子,再拿右手的筷子, 就不会构成死锁! 代码中加锁的时候,不要去“嵌套”。这种做法, 通用性, 不够的。 嵌套,很难避免:有些情况下,确实是需要拿到多个锁, 再进行某个操作的.)
4.循环等待. 多个线程获取多个锁的过程中,出现了循环等待。A 等待 B, B 也等待 A 或者 A 等待 B,B 等待 C, C 等待 A。(约定好加锁的顺序(比如按照编号从小到大的顺序),就可以破除循环等待了)
解决死锁问题,核心思路, 破坏上述的必要条件,只要能破坏一个,就搞定!!上述破坏3 4两种 是开发中比较实用的方法,还有一些其他方案,也能解决死锁问题.但引入加锁顺序的规则(普适性高, 方案容易落地)
死锁的小结:
死锁这里非常重要的,时面试高频的问题。
"谈谈你对于死锁的理解”
死锁:
1.死锁是啥
2.死锁的三个场景
3. 死锁的危害
4.死锁的必要条件, 如何解决死锁
5.2 synchronized 使⽤⽰例
synchronized 本质上要修改指定对象的 “对象头”. 从使⽤⻆度来看, synchronized 也势必要搭配⼀个具体的对象来使⽤.
(1) 修饰代码块: 明确指定锁哪个对象.
锁任意对象
public class SynchronizedDemo {private Object locker = new Object();public void method() {synchronized (locker) {}}
}
锁当前对象
public class SynchronizedDemo {public void method() {synchronized (this) {}}
}
(2) 直接修饰普通⽅法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {public synchronized void methond() {}
}
修饰一个普通方法,就可以省略"锁对象。
等价于:
(3) 修饰静态⽅法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {public synchronized static void method() {}
}
synchronized 修饰普通方法, 相当于给 this 加锁 (锁对象 this)
synchronized 修饰静态方法,相当于给类对象加锁
我们重点要理解,synchronized 锁的是什么.
两个线程竞争同⼀把锁, 才会产⽣阻塞等待.
两个线程分别尝试获取两把不同的锁, 不会产⽣竞争.
- 如果我一个线程加锁,一个线程不加锁,是否会存在线程安全问题?
就不会出现锁竞争了!!!会存在线程安全问题 - 如果两个线程,针对不同的对象加锁呢?
也会存在线程安全问题
在一个程序中,锁,不一定只有一把。一个厕所,可能有多个坑位是一样的。每个坑位都有一个锁,如果你两个线程,针对不同的坑位加锁,不会产生互斥的(也称为 锁竞争/锁冲突)。只有是针对同一个坑位加锁,才有互斥。
代码中,可以创建出多个锁。具体写代码的时候,想搞几个锁,就搞几个。只有多个线程竞争同一把锁,才会产生互斥,针对不同的锁,则不会。 - 针对加锁操作的一些混淆的理解
把 count 放到一个 Test.t 对象中. 通过上述 add 方法来进行修改,加锁的时候锁对象,写作 this
synchronized (Test.class){ } 获取类对象 :
在 java 代码中就可以通过类名.class 的方式拿到这个类对象。反射 api 就是从上述对象中获取信息的。
一个 java 进程中, 某个类,只能有唯一一个类对象
synchronized 的变种写法,可以使用 synchronized 修饰方法 。synchronized (this),也可以等价把 synchronized 加到方法上。
方法中还有一个特殊的情况:
static 修饰的方法,不存在 this.(static 修饰的方法,也叫做"类方法,不是针对"实例"的方法,而是针对类的,在这个方法中, 没有 this.) 此时, synchronized 修饰 static 方法, 相当于针对类对象加锁
其他编程语言中,加锁解锁, 都是单独的方法。对比其他语言,java 的加锁操作风格是独树一帜的。Java 中为啥使用 synchronized + 代码块 做法?而不是采用 lock + unlock 函数的方式来搭配呢?
像 C++ 这种写法, 就可能会,忘记调用 unlock(unlock 没有执行到),如果忘记调用 unlock 其他线程都无法获取到这个锁, 产生严重的 bug!!
Java 采取的 synchronized, 就能确保, 只要出了 } 一定能释放锁. 无论因为 return 还是因为 异常,无论里面调用了哪些其他代码,都是可以确保 解锁 操作执行到的.
只要我写了 lock,就会立即加上 unlock 。这种说法,纯纯的,大猪蹄子行为,你给妹子保证,我这辈子只爱你一个,永远不会变心。就算你非常细心,能够确保每个 条件都加 unlock,但是你不能保证,你们组新来的实习生,也能做到这一点(各位同学们, 你们很可能就是这个实习生)
(其实在 Java 中,也有 lock/unlock 风格的锁, 一般很少使用)
但是c++没有 finally ,只能靠程序猿人工来保证了~~(很有可能,java 程序员代码早早写完,也没啥 bug, 下班回去打游戏了,C++ 程序员还在苦苦寻找哪里没有释放锁)。但是更新版本的 C++ 引入了 lock quard (守卫)这个东西,可以起到类似于 synchronized,代码块结束之后,就能自动释放锁。
5.3 Java 标准库中的线程安全类
- Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, ⼜没有任何加锁措施.(把加锁决策交给程序员)
线程不安全.多个线程,尝试修改同一个上述的对象,就很容易出现问题!! 而不是 100%,也可能你这个代码写出来之后,是没问题的,具体代码具体分析(多线程代码,稍微变换一点,就可能有不一样的结果)
• ArrayList
• LinkedList
• HashMap
• TreeMap
• HashSet
• TreeSet
• StringBuilder - 但是还有⼀些是线程安全的. 使⽤了⼀些锁机制来控制.
自带了锁, 在多线程环境下时候,能好点。也不是 100% 不出问题!! 只是概率比上面小很多,具体代码具体分析!!!(多线程代码,稍微变换一点, 就可能有不一样的结果)
像Vector,HashTable,StringBuffer 这几个类都属于是 标准库 即将弃用,不推荐使用,暂时还留着(保持和老的代码兼容)。这个时候,新的代码就不要用了,未来某一天新版本的 jdk,就把这些内容给删了。
• Vector (不推荐使⽤)
• HashTable (不推荐使⽤)
Java 早起,各位 Java 大佬还不够成熟时,引入的设定。现在的话这些设定已经被推翻了,不建议使用了.
• ConcurrentHashMap
相比于 HashTable 来说,高度优化的版本(后续详细分析)
• StringBuffer
StringBuffer 的核⼼⽅法都带有 synchronized .
一旦代码中, 使用了锁,意味着代码可能会因为锁的竞争,产生阻塞=>程序的执行效率大打折扣.
一定要思考清楚, 这个地方是否确食需要锁,不需要的时候不要乱加.
线程阻塞 =>从 cpu 上调度走,啥时候能调度回来继续执行???不好说了~~ 沧海桑田 - 还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的
• String