线程安全
在多线程的模式下,线程间是抢占式执行的。
那么多个线程对同一事件进行并发或者并行的操作得到的结果是否正确是衡量线程安全的标准。
当多个线程访问同一个对象时,**如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,**那这个对象是线程安全的。-- 《深入Java虚拟机》
线程不安全的原因:
线程的执行是抢占式的,这就出现一些问题,主要关注下面前三个:
指令原子性
内存可见性
指令重排序
- …其他
1.指令原子性
原子性指一些机器指令的组合,它们是同生共死的,要么都执行,要不都不执行,执行到一半的话直接抛异常。
实例:使用两个线程对同一变量进行增加,每个变量增加5000次。
public class ThreadSecurity {
static class Counter{
private int count;
public int getCount() {
return count;
}
public void addCount() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSecurity.Counter counter = new ThreadSecurity.Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.addCount();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.addCount();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("sum:"+counter.getCount());
}
}
运行多次结果:
sum:6673
sum:7016
sum:9953
sum:6740
...
原因:看似一个count++代码,背后其实是由三个机器指令组成,load、add和save。由于这三个指令在并发编程时不能保证原子性,所以导致结果不正确,也就是线程不安全。
如上图所示的执行过程,那么一定是正确的结果,如果两个线程都是保持这样的操作,但是多线程情况下不能保证,如下图是其中一种执行情况:
线程1和线程2先后加载了count,这就导致两个线程对同一个结果进行修改,所以最后count只会增加一次。如下图指令执行过程。
- 解决方法:synchronized加锁
public synchronized void addCount() {
count++;
}
结果:
sum:10000
通过加锁使得,当两个线程谁先执行了count++,那么其他线程就不能执行处于阻塞状态,就是说谁先抢到锁谁先执行,执行完毕了然后才能其他线程继续,相当于使得count++这一执行指令原子化,也就是第一个图的样子。
此时如果我们聚焦count++,会产生一个问题:那这两个线程不就没有并发或者并行了吗?
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter.addCount();
}
});
如线程1所示,在除了执行count++外,还涉及循环变量i的读、写和判断还有自增操作以及调用addCount方法这些指令,这些操作都是并行或者并发的存在,只是count++这个语句是原子性的!!!
2.内存可见性
一个线程对共享变量值的修改,能够及时地被其他线程看到。
实例:一个线程根据flag判断是否进行死循环,另一个线程在3s后修改flag,使得线程1结束循环。
public class ThreadSecurity {
public static boolean flag = true;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (flag) {
}
System.out.println("finnish");
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
});
t1.start();
t2.start();
}
}
结果:
死循环
在多线程模式下,当一个线程对一个变量进行多次读操作后(线程1的循环任务,读操作是巨量的),编译器会进行优化,直接从寄存器中读取变量而不从内存中读取,所以线程2修改这一变量后,线程1无法感知,这就是内存可见性导致的线程安全问题。
-
解决方法:volatile
当一个共享变量被volatile修饰时,它会保证线程的操作最后都落实到内存上,当有其他线程需要读取时,它会去内存中读取新值。
public volatile static boolean flag = true;
结果:
finnish
注意volatile不能保证指令的原子性!!!
3.指令重排序
java平台包括两种编译器:静态编译器(javac)和动态编译器(jit:just in time)。静态编译器是将.java文件编译成.class文件(二进制文件),之后便可以解释执行。动态编译器是将.class文件编译成机器码,之后再由jvm运行。问题一般会出现在动态编译器上,因为动态编译器为了程序的整体性能会进行指令重排序,虽然重排序可以提升程序的性能,但是重排序之后会导致源代码中指定的内存访问顺序与实际的执行顺序不一样。
实例:
Student s; 学生类型的引用
线程1的任务:
s = new Student();
线程2的任务:
if(s!=null){
s.run();
}
s = new Student();划分可以分为三个方面:
-
申请内存空间
-
构造方法
-
赋值给s
如果编译器优化进行指令重排序,线程1先执行指令1,3,然后线程2开始执行,线程2会遇到无run方法的问题。
- **解决方法:volatile **
volatile关键字可以阻止编译器进行指令重排序,从而保证一定的“有序性”。