我正在参与掘金创作者训练营第5期,点击了解活动详情
1、多线程环境下的并发安全性
多线程环境下,存在这样一种共享变量,它们在多个线程中都可以访问。由于可能有多个线程会几乎同时对变量进行操作,因此很容易出现并发安全性问题。
举个栗子~
在单线程下,我们对一个数进行10000次自增操作,如下:
public class Add {
public static void main(String[] args) {
System.out.println(new UnsafeAdd().add());
}
static class UnsafeAdd {
private int count=0;
public int add() {
for (int i = 0; i < 10000; i++) {
count++;
}
return count;
}
}
}
运行结果:
而在多线程下,同样的操作可能会出现意料之外的结果:
public class Add {
public static void main(String[] args) {
SafeAdd safeAdd = new SafeAdd();
System.out.println(safeAdd.add());
}
static class SafeAdd {
private int count=0;
public int add() {
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10; j++) {
count++;
}
}
}).start();
}
try {
Thread.sleep(100);//睡眠100ms等待子线程执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
return count;
}
}
}
运行结果(需要多运行几次,不一定每次都会出错):
2、异常结果出现的原因
为什么会出现上面的异常情况呢?
事实上,count++的计算过程可以分为三步
- 从内存中读取count
- count=count+1
- 将count写入内存
在单线程下,这样的步骤并不会发生什么错误,但是放到多线程下,就不一定了。考虑下面一种情景:
- 在某一线程A中,该线程先读取了count的值,如count=1
- 而此时有另外一个线程B刚刚修改完count,将count改成了2
- 线程A对count进行count=count+1的操作,此时线程A中count的值为2
- 线程A将count写入内存,此时count的值为2
graph TD
1.线程A读取count值为1 --> 2.线程B将内存中count值改为2
2.线程B将内存中count值改为2 --> 3.线程A进行count=count+1操作
3.线程A进行count=count+1操作 --> 4.线程A将count写入内存
1.内存中count值为1-->2.内存中count值为2
2.内存中count值为2-->3.内存中count值为2
3.内存中count值为2-->4.内存中count值为2
发现了吗?尽管进行了两次count自增的操作,但是从运行结果来看只发生了一次。
而这样的结果在很多环境下是不可接受的,即使是一个数字的偏差也可能导致巨大的损失。
3、通过锁来避免同时对共享对象的同时操作
依旧是上面的多线程下将count自增10000次的代码,我们只需要用 synchronized代码块将count++包裹起来,再进行测试会发现,再也没有出现count最终值不等于10000的情况了。
而使用synchronized关键字即为我们通常说的加锁。(除此之外还有其他的加锁方式,这里不多作展开)
public class Add {
public static void main(String[] args) {
SafeAdd safeAdd = new SafeAdd();
System.out.println(safeAdd.add());
}
static class SafeAdd {
private int count=0;
public int add() {
for (int i = 0; i < 1000; i++) {
new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 10; j++) {
synchronized (this){//这里进行修改
count++;
}
}
}
}).start();
}
try {
Thread.sleep(100);//睡眠100ms等待子线程执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
return count;
}
}
}
4、锁是怎么实现保护线程安全性的
4.1、理解锁的工作流程
单单讲通过synchronized显然是过于浅显了,再深入展开下锁的实现原理。
synchronized (this)
{ //获得钥匙,操作宝箱
count++;
} //操作完毕,归还钥匙
-
我们将被上锁的代码片段想象成一个宝箱,要获得钥匙才可以对宝箱内部进行操作。
-
而各个线程在并发执行到加锁片段时需要争夺那根钥匙,才能操作宝箱。
-
在操作完之后,要将钥匙归还,以供其他线程使用(争夺)。
4.2、锁的工作原理
- 在上述代码中,this便起到了钥匙的作用,事实上,可以当作钥匙的对象有许多,只需要满足以下条件即可:
-
钥匙可以是任何一个类的对象。
-
这个对象在启动的各个多线程内都可以访问。
-
之所以任何对象都可以被当作钥匙的原因是在jvm中,在每个对象的头部会维护一个被称为mark world的片段,在其中有着一个指向锁的指针,因此将对象当场钥匙,实质上用到的是其mark world中指向的锁,下面我们不再称之为钥匙。
-
在mark world中指向的锁中,维护了一个计数器(初始为0)
-
当进入被加锁片段时,会调用monitorenter指令,将计数器值加一,此时如果其他线程想使用锁,发现计数器值不为0,便不法获得锁。
-
当退出被加锁片段时,会调用monitorexit指令,将计数器值减一,当计数器值为0时,别的线程就可以获得锁了。
-
计数器的值并不只有0和1,详情可以查阅可重入锁的概念
-
-
在上述的过程中,不难看出与不加锁条件下的区别:
- 不加锁时:多个线程可以同时操作count,因此容易带来误差
- 加锁后:在同一时刻,因为只有一把钥匙,也只有一个线程可以获取钥匙,至多只有一个线程能够操作count
5、小结
在本文中,我们从分析为什么会出现并发安全性问题出发,引入了锁的概念,并且从底层原理出发,对synchronized关键字是如何保护并发安全作了较为简单的解释。