文章目录
- 可见性
- 导致可见性的原因
- 线程交叉执行
- 重排序结合线程交叉执行
- 共享变量更新后没有及时更新
- 如何解决可见性问题
- 原子性
- 出现原子性问题的原因
- 如何解决原子性问题
- 有序性
- 导致有序性的原因
- 如何解决有序性问题
- 总结
可见性
内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
导致可见性的原因
线程交叉执行
线程交叉执行多数情况是由于线程切换导致的,例如下图中的线程A在执行过程中切换到线程B执行完成后,再切换回线程A执行剩下的操作;此时线程B对变量的修改不能对线程A立即可见,这就导致了计算结果和理想结果不一致的情况。
重排序结合线程交叉执行
例如下面这段代码
int a = 0; //行1
int b = 0; //行2
a = b + 10; //行3
b = a + 9; //行4
如果行1和行2在编译的时候改变顺序,执行结果不会受到影响;
如果将行3和行4在变异的时候交换顺序,执行结果就会受到影响,因为b的值得不到预期的19;
由图知:由于编译时改变了执行顺序,导致结果不一致;而两个线程的交叉执行又导致线程改变后的结果也不是预期值,简直雪上加霜!
共享变量更新后没有及时更新
因为主线程对共享变量的修改没有及时更新,子线程中不能立即得到最新值,导致程序不能按照预期结果执行。
例如下面这段代码:
public class Visibility {
// 状态标识flag
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
System.out.println(LocalDateTime.now() + "主线程启动计数子线程");
new CountThread().start();
Thread.sleep(100);
// 设置flag为false,使上面启动的子线程跳出while循环,结束运行
Visibility.flag = false;
System.out.println(LocalDateTime.now() + "主线程将状态标识flag被置为false了");
}
static class CountThread extends Thread {
@Override
public void run() {
System.out.println(LocalDateTime.now() + "计数子线程start计数");
int i = 0;
while (Visibility.flag) {
i++;
}
System.out.println(LocalDateTime.now() + "计数子线程end计数,运行结束:i的值是" + i);
}
}
}
运行结果是:
从控制台的打印结果可以看出,因为主线程对flag的修改,对计数子线程没有立即可见,所以导致了计数子线程久久不能跳出while循环,结束子线程。
如何解决可见性问题
1、volatile关键字
volatile
关键字能保证可见性,但也只能保证可见性,在此处就能保证flag的修改能立即被计数子线程获取到。
此时纠正上面例子出现的问题,只需在定义全局变量的时候加上volatile
关键字
// 状态标识flag
private static volatile boolean flag = true;
2、Atomic相关类
将标识状态flag在定义的时候使用Atomic相关类来进行定义的话,就能很好的保证flag属性的可见性以及原子性。
此时纠正上面例子出现的问题,只需在定义全局变量的时候将变量定义成Atomic相关类
// 状态标识flag
private static AtomicBoolean flag = new AtomicBoolean(true);
不过值得注意的一点是,此时原子类相关的方法设置新值和得到值的放的是有点变化,如下:
// 设置flag的值
VisibilityDemo.flag.set(false);
// 获取flag的值
VisibilityDemo.flag.get()
3、锁
此处我们使用的是Java常见的synchronized关键字。
此时纠正上面例子出现的问题,只需在为计数操作i++
添加synchronized
关键字修饰。
synchronized (this) {
i++;
}
通过上面三种方式,都得到类似如下的期望结果:
原子性
一个或者多个操作在 CPU 执行的过程中不被中断的特性。
出现原子性问题的原因
导致共享变量在线程之间出现原子性问题的原因是上下文切换。
那么接下来,我们通过一个例子来重现原子性问题。
package td;
import java.util.ArrayList;
import java.util.List;
/**
* 演示:原子性问题 -> 指当一个线程对共享变量操作到一半时,另外一个线程也有可能来操作共享变量,干扰了第一个线程的操作
*/
public class Atomicity {
//定义一个共享变量
private static int number = 0;
public static void addNumber(){
number++;
}
public static void main(String[] args) throws InterruptedException {
//对number进行1000的++
Runnable runnable = () -> {
for (int i = 0; i < 1000; i++) {
addNumber();
}
};
List<Thread> list = new ArrayList<>();
//使用10个线程来进行操作
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
list.add(t);
}
for (Thread t : list) {
//t.join()方法只会使主线程进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程
t.join();
}
System.out.println("number = " + number);
}
}
多次运行上面的程序,也有我们期望的结果 number = 10000
,当时会出现不是我们想要的结果。
出现上面情况的原因就是因为:
public static void addNumber(){
number++;
}
这段代码并不是原子操作,其中的number
是一个共享变量。在多线程环境下可能会被打断。就这样原子性问题就赤裸裸的出现了。
如何解决原子性问题
1、synchronized关键字
synchronized既可以保证操作的可见性,也可以保证操作结果的原子性。
所以,此处我们只需要将addNumber()方法设置成synchronized的就能保证原子性了。
public synchronized static void addNumber(){
number++;
}
2、Lock锁
static Lock lock = new ReentrantLock();
public static void addNumber(){
lock.lock();//加锁
try{
number++;
}finally {
lock.unlock();//释放锁
}
}
Lock锁保证原子性的原理和synchronized类似
3、原子操作类型
JDK提供了很多原子操作类来保证操作的原子性。比如最常见的基本类型:
AtomicBoolean
AtomicLong
AtomicDouble
AtomicInteger
这些原子操作类的底层是使用CAS机制的,这个机制保证了整个赋值操作是原子的不能被打断的,从而保证了最终结果的正确性。
和synchronized相比,原子操作类型相当于是从微观上保证原子性,而synchronized是从宏观上保证原子性。
public class Atomicity {
//定义一个共享变量
private static AtomicInteger number = new AtomicInteger();
public static void add(){
number.incrementAndGet();
}
public static int get(){
return number.get();
}
public static void main(String[] args) throws InterruptedException {
//对number进行1000的++
Runnable runnable = () -> {
for (int i = 0; i < 1000; i++) {
add();
}
};
List<Thread> list = new ArrayList<>();
//使用5个线程来进行操作
for (int i = 0; i < 10; i++) {
Thread t = new Thread(runnable);
t.start();
list.add(t);
}
for (Thread t : list) {
//t.join()方法只会使主线程进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程
t.join();
}
System.out.println("number = " + get());
}
}
有序性
有序性问题 指的是在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题。
用图示就是:
导致有序性的原因
如果一个线程写入值到字段 a,然后写入值到字段 b ,而且b的值不依赖于 a 的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新b的值到主内存。此时就可能会出现有序性问题。
例子:
public class Order {
static int value = 1;
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 500; i++) {
value = 1;
flag = false;
Thread thread1 = new DisplayThread();
Thread thread2 = new CountThread();
thread1.start();
thread2.start();
System.out.println("=========================================================");
Thread.sleep(4000);
}
}
static class DisplayThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " DisplayThread begin, time:" + LocalDateTime.now());
value = 1024;
System.out.println(Thread.currentThread().getName() + " change flag, time:" + LocalDateTime.now());
flag = true;
System.out.println(Thread.currentThread().getName() + " DisplayThread end, time:" + LocalDateTime.now());
}
}
static class CountThread extends Thread {
@Override
public void run() {
if (flag) {
System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
System.out.println(Thread.currentThread().getName() + " CountThread flag is true, time:" + LocalDateTime.now());
} else {
System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
System.out.println(Thread.currentThread().getName() + " CountThread flag is false, time:" + LocalDateTime.now());
}
}
}
}
运行结果:
从打印的可以看出:在 DisplayThread 线程执行的时候肯定是发生了重排序,导致先为 flag 赋值,然后切换到 CountThread 线程,这才出现了打印的 value 值是1,falg 值是 true 的情况,再为 value 赋值;不过出现这种情况的原因就是这两个赋值语句之间没有联系,所以编译器在进行代码编译的时候就可能进行指令重排序。
用图示,则为:
如何解决有序性问题
1、volatile关键字
volatile 的底层是使用内存屏障来保证有序性的(让一个Cpu缓存中的状态(变量)对其他Cpu缓存可见的一种技术)。
volatile 变量有条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。并且这个规则具有传递性,也就是说:
使用 volatile 修饰flag
就可以避免重排序和内存可见性问题。写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
此时,我们定义变量 flag 时使用 volatile 关键字修饰,如:
private static volatile boolean flag = false;
此时,变量的含义是这样子的:
也就是说,只要读取到 flag=true;
就能读取到 value=1024
;否则就是读取到 flag=false;
和 value=1
的还没被修改过的初始状态;
但也有可能会出现线程切换带来的原子性问题,就是读取到 flag=false;
而 value=1024
的情况。
2、加锁
此处我们直接采用Java语言内置的关键字 synchronized,为可能会重排序的部分加锁,让其在宏观上或者说执行结果上看起来没有发生重排序。
代码修改也很简单,只需用 synchronized 关键字修饰run方法即可,代码如下:
public synchronized void run() {
value = 1024;
flag = true;
}
总结
最后,简单总结下几种解决方案之间的区别:
特性 | Atomic变量 | volatile关键字 | Lock接口 | synchronized关键字 |
---|---|---|---|---|
原子性 | 可以保障 | 无法保障 | 可以保障 | 可以保障 |
可见性 | 可以保障 | 可以保障 | 可以保障 | 可以保障 |
有序性 | 无法保障 | 一定程度保障 | 可以保障 | 可以保障 |