176 lines
6.1 KiB
Markdown
176 lines
6.1 KiB
Markdown
---
|
||
title: 自增的线程安全问题
|
||
date: 2017-10-9 20:48:56
|
||
tags:
|
||
- 线程
|
||
categories:
|
||
- Java
|
||
---
|
||
|
||
Java中的 `i++` 操作也是有可能存在线程安全问题的
|
||
如果i是方法内的局部变量 , 则一定是线程安全 , 因为每个方法栈是线程私有的
|
||
若i是多个线程可见的变量 , 则存在线程安全问题
|
||
<!-- more -->
|
||
原因 : 这个操作不是原子性操作 , 在内存中的执行是分为3步的 , `读值` -> `+1` -> `写值`
|
||
在这3步之间都可能会有CPU调度产生 , 造成值被修改 , 造成脏读脏写
|
||
|
||
```java
|
||
private static int num = 0;
|
||
|
||
private static void cnt() {
|
||
for(int i=0 ; i<100 ; i++) {
|
||
num ++;
|
||
}
|
||
}
|
||
|
||
public static void main(String[] args) {
|
||
List<Thread> thList = new ArrayList<Thread>();
|
||
|
||
for(int i=0 ; i<10 ; i++) {
|
||
Thread thread = new Thread(){
|
||
@Override
|
||
public void run() {
|
||
try {
|
||
//由于自增操作的运算速度要远快于创建和启动线程
|
||
//执行一个等待去保证所有的子线程都构造完成以后再执行自增操作
|
||
Thread.sleep(100);
|
||
} catch (InterruptedException e) {
|
||
e.printStackTrace();
|
||
}
|
||
cnt();
|
||
}
|
||
};
|
||
thread.start();
|
||
thList.add(thread);
|
||
}
|
||
while(true) {
|
||
boolean flag = true;
|
||
//判断是否所有子线程都已结束
|
||
for(Thread thread : thList) {
|
||
flag = flag && !thread.isAlive();
|
||
}
|
||
if(flag) {
|
||
break;
|
||
}
|
||
}
|
||
System.out.println(num);
|
||
}
|
||
```
|
||
以上程序的执行结果多数情况都不是1000 , 虽然num++确实被执行了1000次
|
||
这就是不同线程之间的脏读和脏写造成的
|
||
|
||
----
|
||
#### volatile
|
||
这是Java当中的一个关键字 , 它的作用概括来说有两点 : `可见性` 和 `有序性`
|
||
+ **可见性 :** 当一个线程修改了这个变量的值以后 , 其他线程在尝试读取这个值的时候看到的一定是新值
|
||
因为运算的操作只有在CPU当中才能执行 , 使用volatile修饰的变量在执行修改以后会立即写入内存 , 而其他线程需要读取值的时候也一定会直接去内存当中读取
|
||
反映到硬件层的话 , 就是cpu中的缓存无效
|
||
> 从上面的例子来说就是 **+1** 和 **写值**两个操作之间不会被其他线程中断
|
||
|
||
+ **有序性 : **禁止进行指令重排序
|
||
比如如下代码
|
||
```java
|
||
int i=0;
|
||
boolean flag = false;
|
||
i = 1;//语句1
|
||
falg = true;//语句2
|
||
```
|
||
在这种情况下 , 并不能保证语句1一定在语句2之前执行 , 可能会发生指令重排序
|
||
处理器为了提高程序运行效率 , 可能会对输入代码进行优化
|
||
当然这种优化的前提是优化以后程序的执行结果和按照代码顺序执行的结果是一致的
|
||
显然上述代码当中先执行语句1还是语句2都是没问题的( 语句2并不依赖语句1的执行结果 ) , 最终结果也没有差别
|
||
|
||
在单线程中 , 我们通常不会去关注这个问题 , 因为不会对程序的正常执行造成任何影响 , 但是多线程就不同了
|
||
```java
|
||
//线程A
|
||
context = loadContext();//语句1
|
||
inited = true;//语句2
|
||
|
||
//线程B
|
||
while(!inited) {
|
||
sleep();
|
||
}
|
||
doSomething(context);
|
||
```
|
||
如果只看线程A , 那么语句1和语句2的执行先后并没有什么差别
|
||
但是如果线程A真的先执行了语句2 , 然后cpu调度切换到了线程B , 那么doSomething就会报错 , 因为此时context还没有被初始化
|
||
|
||
> 指令重排序不会影响单线程的执行 , 但是会影响到线程并发执行的正确性
|
||
|
||
|
||
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
|
||
------摘自<深入理解Java虚拟机>
|
||
|
||
lock前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供3个功能:
|
||
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
|
||
2)它会强制将对缓存的修改操作立即写入主存;
|
||
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
|
||
|
||
----
|
||
#### 原子性
|
||
如果一个操作是原子性的 , 就代表它是不可分割的 , 无法被其他线程中断介入的
|
||
可以与事务类比
|
||
|
||
在Java当中 , 对**基本数据类型变量**的`读取`和`赋值`是原子性操作
|
||
但是表现在代码当中 , 是十分不易区分的
|
||
例如
|
||
```java
|
||
x = 10;//语句1
|
||
y = x;//语句2
|
||
x++;//语句3
|
||
x = x+1;//语句4
|
||
```
|
||
在上述的4个语句当中 , 其实只有语句1是原子性的操作
|
||
语句2包含读取x的值和赋值给y两个操作
|
||
3和4则包含读取值 , 进行运算 , 赋值操作
|
||
|
||
根据上述`volatile`的作用 , 可见volatile并**不能**保证原子性
|
||
对于开始的程序 , 即使用volatile修饰num , 最终结果也可能不是1000
|
||
|
||
---
|
||
#### Java中的锁机制
|
||
运用synchronized或者ReentrantLock都可以解决这个问题
|
||
|
||
+ 使用synchronized
|
||
```java
|
||
private static int num = 0;
|
||
private static Object obj = new Object();
|
||
|
||
private static void cnt() {
|
||
for(int i=0 ; i<100 ; i++) {
|
||
synchronized (obj) {
|
||
num ++;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
+ 使用ReentrantLock
|
||
```java
|
||
private static int num = 0;
|
||
private static ReentrantLock objLock = new ReentrantLock();
|
||
|
||
private static void cnt() {
|
||
for(int i=0 ; i<100 ; i++) {
|
||
objLock.lock();
|
||
num ++;
|
||
objLock.unlock();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### AtomicInteger
|
||
这个类的实例表示可以用原子方式更新的int值
|
||
调用这个类中的可用方法进行自增操作 , 可以保证线程安全
|
||
```java
|
||
private static AtomicInteger num = new AtomicInteger(0);
|
||
|
||
private static void cnt() {
|
||
for(int i=0 ; i<100 ; i++) {
|
||
num.getAndIncrement();//以原子方式获取并且自增1
|
||
}
|
||
}
|
||
```
|
||
>传统的锁机制需要陷入内核态,造成上下文切换,但是一般持有锁的时间很短,频繁的陷入内核开销太大,所以随着机器硬件支持CAS后,JAVA推出基于compare and set机制的AtomicInteger,实际上就是一个CPU循环忙等待。因为持有锁时间一般较短,所以大部分情况CAS比锁性能更优。
|