解决线程安全问题
Posted myarticles
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了解决线程安全问题相关的知识,希望对你有一定的参考价值。
线程带来的风险
- 线程安全性问题
- 出现安全性问题的需要满足的条件:多线程环境、有共享资源、非原子性操作
- 活跃性问题
- 死锁
- 饥饿
- 活锁
- 性能问题
- cpu上下文切换会有性能问题(cpu分时间片执行)
锁
自旋锁
自旋其实就是当一个线程获取到锁之后,其他的线程会进行阻塞等待,一直到这个线程释放锁后才能进入
重入锁 & 锁重入
锁重入即在一个对象中对两个方法都加锁了,那么在一个线程获取到其中一个方法的锁后,再执行另外一个方法时就不再需要获取锁了;同时如果一个线程获取到了其中一个方法的锁,那么其他的线程既不能执行这个方法,也不能执行另一个方法。
死锁
当一个线程永远的持有这把锁而其他线程都尝试获取这把锁的时候就形成了死锁
死锁问题最简单的演示:
public class DeadLock {
private Object obj1 = new Object();
private Object obj2 = new Object();
public void a() throws Exception {
synchronized(obj1) {
Thread.sleep(1000);
synchronized(obj2) {
System.out.println("a");
}
}
}
public void b() throws Exception {
synchronized(obj2) {
Thread.sleep(1000);
synchronized(obj1) {
System.out.println("b");
}
}
}
}
/**
* 上面a跟b方法形成了死锁
* 当a执行后会获取obj1的锁,b执行后会获取obj2的锁
* 这时a想要再获取obj2的锁已经不可能了 而b想要获取obj1的锁也不可能了
* 因此会一直阻塞,谁也不能执行
*/
synchronized原理与使用
synchronized力度是作用于对象上的,有三种用法
- 同步实例方法,锁是当前实例对象
- 将synchronized关键字加载方法前面,锁定的对象是当前类的this对象,跟括号里面写this是一样的。这样加锁必须保证单例
- 同步类方法,锁是当前类对象
- 如果加了synchronized的方法是静态方法,则锁定的对象是当前实例的class对象
- 同步代码块,锁是括号里面的对象
java的synchronized关键字会被翻译成字节码指令monitorenter
跟monitorexit
,指令之间就是synchronized同步代码块之间执行的代码。
既然任何对象都可以作为锁,那么锁信息又存在对象什么地方呢?答案是存在对象头中。主要是存在于对象头中的Mark Word中的,markword中存储锁的机制如下图所示:
由上面的图可知,jvm内置锁又分为很多个,其实在不断地优化中,jvm的内置锁已经不再是当初那个笨重的锁了,它会根据不同的情况来自动升级,大致的过程是:无锁 ---> 偏向锁 --> 轻量级锁 ---> 重量级锁
偏向锁
在很多情况下,竞争锁的不是由多个线程,而是由一个线程在使用,这时候如果还是像多线程那样去获取锁再释放锁,会浪费很多资源。因此偏向锁非常适用于同一个线程反复进入同一同步块的场景
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
无锁状态
对象一开始是没有加锁的,当一个线程访问同步块的时候会检查标志位,如果是01表示要么是偏向锁要么是无锁,如果不是偏向锁,那么会CAS修改对象头MarkWord中的偏向位为1,变成偏向锁,然后会将对象头的Mark Word中前23位放入当前线程的ID,最后执行同步块当中的代码,等到达安全点后会暂停线程,这里的安全点是指cpu分配给当前线程的时间片用完,这个时候会进行判断,看是否已经执行完了同步块中的代码,如果执行完了就没有必要加锁了,那么偏向锁会被释放,Mark Word中的线程ID会被清除,偏向会置为0;如果没有执行完则会进一步将锁升级为轻量级锁。
有锁状态
当一个线程访问同步块,检查偏向为1,表示是偏向锁,这时候它会使用CAS修改对象头前23位为自己的ID,但是由于线程ID已经有了,所以一定会修改失败,这时候它会等待占用锁的线程到达安全点后撤销偏向锁,将锁升级为轻量级锁。下图红色部分就是新的线程进入时的流程。
轻量级锁
适用于线程交替执行的场景。
再轻量级锁的状态下,每个线程都会将markword的信息复制到自己线程栈的栈帧中,然后尝试修改markword中的锁标志位为轻量级锁,并且对象的markword中会留下获取到锁的线程的信息,这个信息是一个指针,指向刚刚这个线程复制的markword信息。随后的线程会尝试修改markword中的内容,但是由于第一个线程正在占用,所以是修改不了的,只能不断的等待,不断的重新尝试修改,最终等到第一个线程释放锁后才能修改成功。这个过程叫做自旋。在第二个线程获取到锁后就会将锁升级到重量级锁。
重量级锁
重量级锁的性能非常低,适合高并发场景。因为高并发的场景最终的结果一定是会升级到重量级锁,所以不如一开始就使用重量级锁,以免锁升级的过程中造成过多的资源浪费。
Volatile
要理解volatile的作用,需要先了解java的内存模型jmm
在上面的图中可以看到每个线程都会从主内存备份一份数据到自己的工作内存中,但是现在有一个问题,假设某个变量在线程B中被修改了,而由于线程B修改后的数据只会同步到主内存中,而不会影响到线程A中工作内存的数据,这就使得数据同步出现了问题,具体看下面代码:
public class Volicity {
private static boolean flag = false;
public static void main(String[] args) {
// 线程A等待线程B的数据
new Thread(() -> {
System.out.println("等待准备数据。。。");
while(!flag) {
}
System.out.println("启动系统。。。");
}).start();
new Thread(() -> {
System.out.println("准备数据。。。");
flag = true;
System.out.println("数据准备完成。。。");
}).start();
}
}
在上面的程序中线程A会等待线程B修改数据,修改完成后会继续往下执行,但是结果是即便线程B修改了数据,线程A仍然会停在循环处不会执行,这就是因为线程B修改后的数据不会影响到线程A的工作内存中的数据。
Volatile解决
上面的问题的解决办法就是在变量前面添加volatile
关键字
private static volatile boolean flag = false;
为什么加了volatile关键字后就能够进行数据同步了呢
在说明这个问题之前需要先了解JMM将数据从主内存读到工作内存以及再同步回来的原理
JMM主要是通过如下原子操作实现的:
- read:从主内存中读取数据
- load:将主内存读取到的数据写入到工作内存中
- use:从工作内存读取数据来计算
- assign:将计算好的值重新赋值到工作内存中
- store:将工作内存数据写入主内存
- write:将store过去的变量值赋值给主内存中的变量
- lock:将主内存变量加锁,标识为线程独占状态
- unlock:将主内存变量解锁,解锁后其他线程可以锁定该变量
JMM就是通过如上的一些方法来实现主内存与各个线程之间的工作内存进行数据同步的
下面可以将上面的程序执行流程捋一遍:
- 首先线程A启动后通过read方法读取到主内存中的flag变量,然后使用load方法将数据存到自己的工作内存中
- 然后使用use方法从工作内存中读取到数据取反进行判断,判断结果为true,那么会一直卡在这里
- 这时线程B启动,同样通过read方法从主内存中读取数据,然后通过load方法将数据存到自己的工作内存中
- 然后程序继续执行,使用use方法修改flag的值为true,然后使用assign方法将计算好的值重新赋值到工作内存中
- 然后使用store方法将工作内存中修改了的数据写入主内存
- 最后使用write方法将store过去的变量赋值给主内存中的变量
具体流程图如下:
现在问题出现了:当线程B将数据推送到主内存后,线程A并不知道,它仍旧使用的是自己工作内存中没有更新的数据,所以会出问题
由此可知其实volatile关键字的作用就是当主内存中的数据改变后及时的将主内存的数据同步到线程A的工作内存中,那么它是如何做到的呢?
volatile解决JMM缓存不一致问题
为了解决上面的问题,在不同的时期使用了不同的方法,早期的时候使用的是总线加锁,但是由于性能太低,后来使用了MESI缓存一致性协议
- 总线加锁(性能太低):cpu从主内存读取数据到高速缓存,会在总线对这个数据加锁,这样其他cpu没法去读或写这个数据,直到这个cpu使用完数据释放锁之后其它cpu才能读取到该数据
- MESI缓存一致性协议:多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到总线上传播的数据的变化从而将自己缓存里的数据失效
volatile缓存可见性实现原理
底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并会写到主内存
下面是对lock指令的解释:
- 会将当前处理器缓存行的数据立即写回到系统内存
- 这个写回内存的操作会引起在其他cpu里缓存了该内存地址的数据无效(MESI协议)
synchronized解决
其实除了能够使用volatile解决可见性问题外,还能够使用synchronized解决可见性问题,只需要将程序修改为如下即可:
public class Demo01 {
private static boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
System.out.println("等待准备数据。。。");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Demo01.class) {
while (!flag) {
}
System.out.println("启动系统。。。");
}
}).start();
new Thread(() -> {
System.out.println("准备数据。。。");
synchronized (Demo01.class) {
flag = true;
}
System.out.println("数据准备完成。。。");
}).start();
}
}
上面在对flag变量进行读写时都加了锁,其实道理很简单,这也是synchronized的特性导致的:
- 线程解锁前,必须把共享变量的最新值刷新到主内存中
- 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量是需要从主内存中重新读取最新的值(加锁与解锁需要统一把锁)
其实对比volatile的做法(利用cpu的嗅探机制嗅探主内存的值改变,进而使工作内存中的变量失效,从而重新去主内存中获取值),这种方式只是人为的在读取变量前强制程序去主内存中读取变量。
有序性
上面说到了volatile的一个作用,保证可见性,其实除了可见性,volatile还能够保证程序的有序性,当我们写下的程序交给jvm去执行的时候,jvm并非是按照我们写下的顺序去执行的,而是会先进行一些指令重排,在保证程序正确执行的情况下做到尽可能的优化,例如下面这段例子:
public void test() {
int a;
int b;
int c;
a = 1;
}
上面的代码原本的执行是这样的:首先jvm会在当前线程栈中开辟一块内存作为test方法的栈帧,然后将在栈帧中的局部变量表中为a变量开辟一块内存,然后为b变量开辟一片内存,然后为c变量开辟一片内存;最后将常量1压入栈帧中的操作数栈中,然后从操作数栈中将1弹出,并且存放到局部变量表中a变量所在的区域。
经过指令重排后:其实从上面的过程中我们就可以看出一个问题,在jvm为a变量开辟出内存后,为什么不直接执行a=1的操作呢?这样就能避免后面再去寻找a变量的地址时形成的开销,因此jvm会对指令重排,重排后的代码如下
public void test() {
int a;
a = 1;
int b;
int c;
}
这样做的本意是好的,但是有一个问题,在有些情况下做指令重排会导致一些问题,最著名的就是单例模式中利用双重检测锁创建单例时出现的问题,如下:
public class Singleton {
private Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized(singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在上面的代码中,看似很完美的实现了单例模式,但是由于jvm会进行指令重排,所以最终的结果或许并不如期待的那样,但是如果稍作修改,在singleton变量前面加上volatile关键字,就可以很完美的解决这个问题。
由此可见,volatile保证程序有序性的原因就是能够阻止jvm对指令的重排序
总结
- synchronized能够保证原子性跟可见性
- volatile能够保证可见性跟有序性
- 由于volatile会阻止cpu优化(阻止指令重排),因此会造成性能问题,也要合理使用
- synchronized能够完全替代volatile,但是有些非原子性操作或者不需要保证原子性的时候,使用volatile更加轻量
- 使用这两个关键字就能解决并发编程的三大特点:可见性、原子性、有序性
原子类保证原子性问题
在对数据使用volatile后,虽然能够保证数据在各个线程之间的可见性,但是并不能保证原子性,想要保证数据的原子性,需要使用juc包下面的原子类
原子类大致分为四类:
- 原子更新基本类型
- 原子更新数组
- 原子更新引用类型
- 原子更新引用类型字段
原子更新基本类型
基本类型有AtomicInteger、AtomicBoolean、AtomicLong这几个,基本的使用方法如下:
private AtomicInteger value = new AtomicInteger(0);
value.getAndIncrement(); // 获取值再自加
value.incrementAndGet(); // 自加再获取值
value.getAndAdd(10); // 获取值再加10
LongAddr
jdk1.8之后又推出了一个LongAdder,首先,这个类实现的功能其实是跟AtomicLong一样的,那么为什么有了AtomicLong了还要有LongAddr呢?原因就是AtomicLong性能不是特别好,同一时间只能允许一个线程修改。
那么LongAddr是怎样提升效率的呢?我们可以看到原先的AtomicLong是所有线程去修改一个数,这样自然同一时间只能允许一个线程修改,但是LongAddr是将这个数拆分为了几个数,单个的数还是只能同时允许一个线程修改。譬如6拆分成1 2 3,那么现在有三个线程,它们就可以同时去修改,线程1修改数字1,线程2修改数字2,线程3修改数字3,最后改变的结果是2 3 4,如果用户去获取结果就把这几个部分的数字加起来,也就是9。但是如果再来一个线程,就继续拆分,因此不会存在自旋现象。
DoubleAddr
DoubleAddr跟LongAddr解决的问题是相同的
原子更新数组
有例如AtomicIntegerArray等类,基本操作如下:
private AtomicIntegerArray value = new AtomicIntegerArray(new int[] {1, 2, 5});
value.getAndIncrement(2); // 获取数组第三个元素再加一
value.getAndAdd(2, 10); // 获取数组第三个元素再加10
原子更新引用类型
使用AtomicReference类更新引用类型
private AtomicReference<User> user = new AtomicReference<>();
// 这时更新的时整个user对象
原子更新引用类型字段
- AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
- AtomicLongFieldUpdater:原子更新长整型字段的更新器。
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更数据和数据的版本号,可以解决使用CAS进行原子更新时,可能出现的ABA问题。
对于字段的要求有如下三点:
- 字段必须加上volatile关键字
- 不能是类变量,即字段前面不能加static关键字
- 只能是可修改变量,即字段前面不能加final
// 后面两个参数是要进行原子操作的类以及要修改类中的哪一个字段
AtomicIntegerFieldUpdater<User> old = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
User user = new User("nick", 12);
old.getAndIncrement(user);
System.out.println(user.getAge()); // 此时age字段变为13
Lock接口
前面已经了解了解决线程安全问题的三个方式,分别是使用synchronized、Volatile以及使用原子类,使用synchronized是可以解决所有线程安全性问题的,但是由于比较笨重,使用了volatile替代,但是volatile只能解决可见性跟有序性问题,不能解决原子性问题,于是出现了原子类,但是原子类只能保证单个数据修改的原子性,当要进行一系列的操作的时候仍旧不能够保证原子性,于是就出现了Lock接口。
基本使用
首先注意下面代码的问题:
public class Sequence {
private int value;
public int getNext() {
return value++; // 线程不安全的
}
public static void main(String[] args) {
Sequence s = new Sequence();
for (int i = 0; i < 2; i++) {
new Thread(() -> {
while (true) {
System.out.println(Thread.currentThread().getName() + "==" + s.getNext());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
上面的代码执行value++的操作是线程不安全的,想要解决只需要再value++前后上锁即可
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Sequence {
private int value;
private Lock lock = new ReentrantLock();
public int getNext() {
lock.lock(); // 上锁
value++;
lock.unlock(); // 释放锁
return value;
}
public static void main(String[] args) {
Sequence s = new Sequence();
for (int i = 0; i < 2; i++) {
new Thread(() -> {
while (true) {
System.out.println(Thread.currentThread().getName() + "==" + s.getNext());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
使用lock.lock()以及lock.unlock()方法即可对操作进行上锁,需要注意的是这时的锁对象lock必须是同一个,也就是多个线程使用同一把锁,否则是没有用的
Lock的好处
- Lock相比于synchronized需要显示的获取和释放锁,但是换来了更灵活的操作,例如可以在任意地方释放锁
- Lock可以方便的实现线程执行的公平性
- 能够非阻塞的获取锁,能被中断的获取锁,能超时获取锁
AQS
AQS即AbstractQueuedSynchronizer,是实现各种阻塞锁以及各种同步容器的基础。
使用AQS实现自己的锁
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedLongSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
public class MyLock2 implements Lock {
private Helper helper = new Helper();
private class Helper extends AbstractQueuedLongSynchronizer {
@Override
protected boolean tryAcquire(long arg) {
// 如果是第一个线程进来 可以拿到锁 返回true
// 第二个线程进来拿不到锁 返回false
int state = (int) getState();
if (state == 0) {
if (compareAndSetState(0, arg)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
} else if (getExclusiveOwnerThread() == Thread.currentThread()) {
setState(state + arg);
return true;
}
return false;
}
@Override
protected boolean tryRelease(long arg) {
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new RuntimeException("所被其他线程占用");
}
int state = (int) (getState() - arg);
setState(state);
if (state == 0) {
setExclusiveOwnerThread(null);
return true;
}
return true;
}
public Condition newCondition() {
return new ConditionObject();
}
}
@Override
public void lock() {
helper.acquire(1);
}
@Override
public void lockInterruptibly() throws InterruptedException {
helper.acquireInterruptibly(1);
}
@Override
public boolean tryLock() {
return helper.tryAcquire(1);
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
return helper.tryAcquireNanos(1, unit.toNanos(time));
}
@Override
public void unlock() {
helper.release(1);
}
@Override
public Condition newCondition() {
return helper.newCondition();
}
}
读写锁
前面所了解的锁都是排他锁,也就是同一个时间里面只能允许一个线程进行访问,但是在有些时候并不需要如此,例如在读操作的时候可以同时多个线程访问,这时候的锁可以设置为共享锁。
对于读写锁有:读跟读是互斥的、读跟写是互斥的、读跟读是不互斥的
下面简单实现读写锁的用法:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo {
private Map<String, Object> map = new HashMap<>();
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock readLock = lock.readLock();
private Lock writeLock = lock.writeLock();
public Object get(String key) {
readLock.lock();
System.out.println(Thread.currentThread() + "读操作开始..");
Object o = map.get(key);
readLock.unlock();
System.out.println(Thread.currentThread() + "读操作结束..");
return o;
}
public void put(String key, Object value) {
writeLock.lock();
System.out.println(Thread.currentThread() + "写操作开始..");
map.put(key, value);
writeLock.unlock();
System.out.println(Thread.currentThread() + "写操作结束..");
}
}
读写锁实现原理
锁降级
锁降级是指写锁降级为读锁,原理就是在写锁还没释放的时候将锁设置为读锁,以致让别的写线程没办法竞争到写锁
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo1 {
private Map<String, Object> map = new HashMap<>();
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock readLock = lock.readLock();
private Lock writeLock = lock.writeLock();
private volatile boolean isUpdate;
public void readWrite() {
if (isUpdate) {
writeLock.lock(); // 在此处添加写锁
map.put("xxx", "xxx");
readLock.lock(); // 在这里进行锁降级
/*
* 在这里释放写锁后其他的写线程会来竞争写锁继续写数据,为了不让其他的线程来写数据
* 应该在写锁释放前将锁降级为读锁 这样其他的写线程就没办法竞争到写锁了
* 因为读锁与写锁互斥
*/
writeLock.unlock();
}
System.out.println(map.get("xxx"));
readLock.unlock();
}
}
StampedLock
这是jdk1.8出现的一个锁,是对ReentrantReadWriteLock进行的一个增强,之所以出现这个类是因为读写锁经常会遇到一个问题,再高并发的环境下,读的线程远远大于写的线程,由于读写互斥,可能导致写线程饥饿问题,如果使用读写锁的公平模式又会导致性能问题,因此急需要一个类对读写锁进行增强。
在StampedLock中读锁是不会阻塞写锁的,那么如何保证读写一致性呢?解决的方法很简单,就是在读的过程中如果发现了写操作就重新读。
在StamptedLock中分乐观锁跟悲观锁,悲观锁跟读写锁没什么区别,都是读写互斥。只有乐观锁是读写不互斥的。
悲观锁演示:
import java.util.concurrent.locks.StampedLock;
public class StamptedLockDemo {
private StampedLock stampedLock = new StampedLock();
private int balance;
public void read() {
long stampted = stampedLock.readLock();
int c = balance;
System.out.println(c);
stampedLock.unlockRead(stampted);
}
public void write(int value) {
long stampted = stampedLock.writeLock();
balance += value;
stampedLock.unlockWrite(stampted);
}
}
乐观锁演示:
乐观锁主要是在读锁上进行更改,只需要在读取后进行一次判断,如果判断结果是写锁修改了数据,就重新读一次
import java.util.concurrent.locks.StampedLock;
public class StamptedLockDemo {
private StampedLock stampedLock = new StampedLock();
private int balance;
// 读锁示例
public void read() {
long stampted = stampedLock.tryOptimisticRead();
int c = balance;
// 这里可能会出现写操作,因此要进行判断
if (!stampedLock.validate(stampted)) {
try {
// 发生了写操作 重新读取
stampted = stampedLock.readLock();
c = balance;
} finally {
// 释放锁
stampedLock.unlockRead(stampted);
}
}
System.out.println(c);
}
/**
* 读写锁转换
* @param value
*/
public void conditionReadWrite(int value) {
long stampted = stampedLock.readLock(); // 拿到悲观的读锁,方便下面判断数据
while (balance > 0) {
// 将读锁转换为写锁修改数据
stampted = stampedLock.tryConvertToWriteLock(stampted);
if (stampted != 0) { // 成功转换为写锁
// 进行修改操作
balance += value;
break;
} else { // 没有转换成功
// 需要先释放读锁,然后再拿到写锁
stampedLock.unlockRead(stampted);
// 获取写锁
stampted = stampedLock.writeLock();
}
}
stampedLock.unlock(stampted); // 释放任何的锁
}
}
线程安全性问题总结
出现线程安全性问题的条件
- 在多线程条件下
- 必须有共享资源
- 对共享资源进行原子性操作
解决线程安全性问题的途径
- synchronized(通吃 但是效率低)
- volatile保证线程可见
- 原子类
- 使用Lock
认识的锁
- 偏向锁
- 轻量级锁
- 重量级锁
- 重入锁
- 自旋锁
- 共享锁
- 独占锁
- 排他锁
- 读写锁
- 公平锁
- 非公平锁
- 死锁
- 活锁
伪共享问题
以上是关于解决线程安全问题的主要内容,如果未能解决你的问题,请参考以下文章
阶段1 语言基础+高级_1-3-Java语言高级_05-异常与多线程_第3节 线程同步机制_4_解决线程安全问题_同步代码块