5. 多线程(3) --- synchronized
文章目录
- 前言
- 1. 如何解决线程安全问题 [回顾]
- 2. synchronized 关键字
- 2.1. 示例
- 2.2.对示例进行变化
- 2.3 synchronized的其他写法
- 2.4 synchronized的特性
- 2.4.1 互斥
- 2.4.2. 刷新内存
- 2.4.3. 可重入
前言
前面我们通过在两个线程中共同对count进行加一操作,最后得到的结果和预期不一样,并且还通过画图得到了原因。在这个博客中,我们来解决一下问题—引入synchronized关键字
1. 如何解决线程安全问题 [回顾]
-
操作系统对于线程的调度随机的,抢占式的。
这个是操作系统对于线程的底层设定,我们无法左右。 -
多个线程同时修改同一个变量。
这个和代码的结果直接相关,通过调整代码结构,规避一些线程不安全的代码,但是在有些场景下,必须使用这种方案。
例如,在超卖 / 超买 的问题中,某个商品,库存100件,不可以创建出101个订单吧。
这个就是需求的情况下,需求就是需要多线程同时修改一个变量的。 -
修改操作,不是原子的
通过加锁操作,把之前不是原子的count++ 包裹起来,在count++之前,先加锁,然后进行 count++,计算完毕之后,在解锁。
执行完这三步,其他线程就无法插队了。
加锁操作,不是把线程锁死到 CPU上,禁止这个线程被调度走,而是禁止让其他线程重新加这个锁,避免其他线程的操作,在当前线程的执行过程中插队。
2. synchronized 关键字
2.1. 示例
加锁 / 解锁 本身是操作系统提供的 api,很多编程语言都对于这样的 api 进行了封装,大多数的封装风格,都是采用两个函数 lock() 和 unlock()
- lock(); // 加锁
- // 执行一些要保护起来的逻辑
- unlock(); // 解锁
在Java中,使用 synchronized 这样的关键字,搭配代码块,来实现类似的效果。
synchronized(){ // 进入代码块,就相当于加锁
// 执行一些保护的逻辑
} // 出了代码块,就相当于 解锁
我在在代码中使用synchronized关键字。
public class Demo16 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0;i < 50000;i++){
synchronized (){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
其中synchronized() 括号中的参数应该填什么呢?
填写的是用来加锁的对象,要加锁,要解锁,顾名思义,前提得有一把锁,在Java中,任何一个对象都有用作成"锁"。
这个对象的类型不重要,重要的是,是否有多个线程尝试针对这同一个对象进行加锁,换言之,是否多个线程同时竞争同一把锁。
那么我们现在就创建一把锁,放到参数中,然后运行看一下结果。
public class Demo16 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
synchronized (object){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0;i < 50000;i++){
synchronized (object){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
生成了一个object的锁对象,两个线程,使用同一把锁,才会产生互斥的效果。这是因为,一个线程加上了锁,另一个线程就得阻塞等待,等到第一个线程释放锁之后,才有机会拿到锁。
反之,如果采用不同的锁对象,此时不会产生互斥的效果,线程安全就没有得到改变。
我们可以对上面的进行修改,例如 把 synchronized放到 for循环的外面,或者是把锁对象类型进行变化,然后观察一下现象是否发生改变,我们说干就干!
2.2.对示例进行变化
- 把 synchronized放到 for循环的外面,为什么这个操作是可以的呢?
这是因为t1和t2线程在并发执行过程中,相当于只有 count++ 这个操作,会涉及到互斥,for 循环里的条件判断 (i<50000) 和 i++ 这两个操作不涉及到互斥,所以可以直接把 synchronized放到 for 循环外面。
Demo16
public class Demo16 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
for (int i = 0; i < 50000000; i++) {
synchronized (object){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0;i < 50000000;i++){
synchronized (object){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
Demo17
public class Demo17 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
synchronized (locker){
for (int i = 0; i < 50000000; i++) {
count++;
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker){
for (int i = 0; i < 50000000; i++) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
这两种写法,都可以得到我们想要的结论,我们可以通过画图,来分析一下他们的在底层上的细微差别。
这个是Demo16的
这个是Demo17的
通过我们的画图分析,我们发现第一种的情况是比较好的,相较于 第二种。
第二种还有一种写法,其实在上一篇博客中写过了,我们再拿过来看看。
/**
*
* @author admin
* @date 2024/11/25
* @Description
*/
public class Demo15 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0 ;i<50000;i++){
count++;
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t1.join();
t2.start();
t2.join();
System.out.println("count:"+count);
}
}
这个代码不就是串行执行吗,t1一直在join阻塞等待,直到t1结束以后,t2才开始执行,跟上面synchronized的效果是一样的。
解释完第一个,那我们就看一下第二个吧。
2. 把锁对象类型进行变化,然后观察一下现象是否发生改变
我们把锁对象都换成t1,看看情况如何。
/**
* @Author: XXHH
* @CreateTime: 2024-12-05
*/
public class Demo18 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
Thread thread = Thread.currentThread();
for (int i = 0; i < 500000; i++) {
synchronized (thread){
count++;
}
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 500000; i++) {
synchronized (t1){
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+count);
}
}
2.3 synchronized的其他写法
我们之前再讲String的时候,提到过StringBuilder和StringBuffer这两个类的时候,我们讲到了StringBuilder是不安全的,StringBuffer是安全的。我们现在观察一下他们的源码,从哪里可以看到是否安全?
根据上面的截图或者是大家看源码,我们可以发现,StringBuffer 的主要方法都有synchronized关键字,而StringBuilder则没有,因此StringBuffer 可以有效保证线程安全。
当然我们看这个源码还有一个用途,我们发现synchronized可以修饰方法,我们也可以把上面的代码改成方法的形式。
/**
* @Author: XXHH
* @CreateTime: 2024-12-05
*/
class Counter{
private int count = 0;
synchronized public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class Demo18 {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(()->{
for (int i = 0; i < 500000; i++) {
counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 500000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+counter.getCount());
}
}
我们为此直接创建一个Counter类,里面实现一个具有synchronized的add方法,然后观察现象。
同理我们也可以为静态方法来使用synchronized进行修饰。
/**
* @Author: XXHH
* @CreateTime: 2024-12-05
*/
class Counter{
// private int count = 0;
public static int count;
/*synchronized public void add(){
count++;
}
public int getCount(){
return count;
}*/
public synchronized static void add(){
count++;
}
public static int getCount(){
return Counter.count;
}
}
public class Demo19 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for (int i = 0; i < 500000; i++) {
Counter.add();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 500000; i++) {
synchronized (Counter.class){
Counter.count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count:"+Counter.getCount());
}
}
我们主要是来观察
public synchronized static void add(){
count++;
}
和
synchronized (Counter.class){
Counter.count++;
}
这俩段代码,通过观察我们发现,效果一样。
2.4 synchronized的特性
分为下面三个,互斥,刷新内存,可重入
2.4.1 互斥
前面的所有代码产生的效果,都是来源于互斥,我们可以用一个例子,形象的比喻一下,我们欢迎我们的助教老师 — 滑稽老铁。
现在有很多滑稽老铁,都要去上厕所,但是只有一个卫生间,首先 滑稽老铁A 先进入到 卫生间,为了防止他人偷窥,插入了一把锁(synchronized),剩余的滑稽老铁只能阻塞等待,等到滑稽老铁A上完了,锁释放了,其余的滑稽老铁蜂拥而至,谁先抢到,谁就先进去,可以不遵守先来后到,这就是整个过程。
在这理解一下阻塞等待:
针对每一把锁,操作系统内部维护了一个等待队列,当这个锁被某个线程占有的时候,其他线程尝试进行加锁,就加不上了,就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒一个新的线程,再来获取到这个锁。
注意:
- 上一个线程解锁之后,下一个线程并不是立即就能获取到锁,而是要靠操作系统来 “唤醒”,这也就是操作系统调度的一部分工作。
- 假设有 A B C 个线程,线程A 先获取到锁,然后 B 尝试获取锁,然后 C 再尝试获取锁,此时B和C都在阻塞队列中排队等待,但是当A释放锁之后,虽然B比C先来的,但是B不一定就能获取到锁,而是要和C重新竞争,并不遵守先来后到的规则。
synchronized的底层是使用操作系统的 mutex lock 来实现的。
2.4.2. 刷新内存
synchronized的工作过程:
(1) 获得互斥锁
(2) 从主内存拷贝变量的最新副本到工作的内存
(3) 执行代码
(4) 将更改后的共享变量的值刷新到主内存中。
(5) 释放互斥锁
所以 synchronized 也能保证内存可见性,具体的代码请看下一个博客volatile部分。
2.4.3. 可重入
synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
例如我们写个代码来观察一下。
public class Demo20 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0;i<50000;i++){
synchronized (locker){
synchronized (locker) {
count++;
}
}
}
});
t1.start();
t1.join();
System.out.println("count:"+count);
}
}
大家一看到这种代码,毋庸置疑,觉得程序员有毛病,这种问题都会犯。但是如果是这样写呢?
/**
* @Author: XXHH
* @CreateTime: 2024-12-05
*/
class Counter2{
private int count;
synchronized public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class Demo20 {
// public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Counter2 counter2 = new Counter2();
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0;i<50000;i++){
/*synchronized (locker){
synchronized (locker) {
count++;
}
}*/
synchronized (locker){
counter2.add();
}
}
});
t1.start();
t1.join();
System.out.println("count:"+counter2.getCount());
}
}
这两段代码块,在不同的位置上,都使用了 synchronized 关键字,这就不容易发现问题。
我们还是看一下用locker和locker2两个锁的情况吧,分析一下
- 第一次进行加锁操作,能够成功 (锁没有人使用)
- 第二次进行加锁,此时意味着,锁对象已经被占用了,第二次加锁,就会触发阻塞等待
要想解除阻塞,需要往下执行才可以,但是要想往下执行,就需要等到第一次的锁被释放,出现这样的问题,称之为“死锁”。
根据我们的分析,上面的代码会出现严重的bug,但是执行成功,说明JVM对 synchronized 引入了可重入的功能和概念。
我们来分析一下,JVM是如何得知的此处是可重入的。
/**
* @Author: XXHH
* @CreateTime: 2024-12-05
*/
class Counter2{
private int count;
synchronized public void add(){
count++;
}
public int getCount(){
return count;
}
}
public class Demo20 {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Counter2 counter2 = new Counter2();
Object locker = new Object();
Thread t1 = new Thread(()->{
for (int i = 0;i<50000;i++){
synchronized (locker){
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
}
});
t1.start();
t1.join();
System.out.println("count:"+count);
}
}
其实是 JVM 是 先引入一个变量,计数器 0, 每次触发 { 的时候,把计数器 ++,每次触发 } 的时候,把计数器 - -,当计数器- - 到 0 的时候,就是真正需要解锁的时候。
JVM 中如何区分 synchronized 的 大括号呢?
{ } 只是 我们看Java 代码的角度理解的,
JVM 看到的是对应的字节码。
字节码中,对应的是不同的指令 { 涉及到加锁指令,} 对应到解锁指令
当然了 if while 的 { } 不会被编译成加锁解锁的指令。
综上,可重入锁的实现原理,关键在于让锁对象,内部保存,当前是线程持有的这把锁。后续有线程针对这个锁加锁的时候,对比一下,所持有者的线程和当前加锁的线程是否是同一个。
写一篇我们讲解 死锁问题,我们不见不散!