四、安全性问题
安全性问题主要有原子性操作、内存可见性、指令重排序三大问题
4.1、原子性操作
4.1.1、问题描述
原子性操作是指一个或一组操作在一个线程执行完成后,另一个线程才能进行该操纵,这些操作是不可切割的,线程不能在这些操作上交替执行。
由于有些计算需要依赖之前读取到的结果,当执行该计算时,需要依赖的值在内存中已经被其他线程修改了,所以他使用了一个错误的值进行计算,得到了错误的结果;
这种不恰当的执行顺序导致出现不正确的结果的情况叫做竞态条件,最常见的竞态条件就是先检查后执行。
4.1.2、代码重现
i++不是一个原子性操作,所以他存在竞态条件。
public class AtomicDemo {
public static void main(String[] args) throws Exception{
test(100, 1);
test(100, 10);
test(100, 100);
test(100, 1000);
test(100, 10000);
test(100, 100000);
}
public static void test(int threadCount, int incrementCount) throws Exception{
Increment increment = new Increment();
Thread[] threads = new Thread[threadCount];
for(int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> {
for(int j = 0; j < incrementCount; j++) {
increment.increment();
}
});
threads[i] = thread;
thread.start();
}
for(int i = 0; i < threadCount; i++) {
threads[i].join();
}
System.out.println(increment.getI());
}
}
class Increment{
private int i
public void increment() {
i++;
}
public Integer getI() {
return i;
}
}
控制台打印结果:
100
995
9933
98201
999840
9843713
4.1.3、解决方案
因为是共享可变变量在多线程中存在这个问题,所以可以在共享和可变的性质上解决。
1)从共享性解决:
A)不要使用共享变量,尽量使用局部变量
B)使用ThreadLocal对象
2)从可变性解决
对变量用final修饰,对象就不可变了。(对于引用对象来说,是引用不可变,引用的内容是可变的)。
3)加锁解决
内置锁synchronized:
加锁语法
synchronized(锁对象){
需要原子操作的代码块
}
在使用synchronized将一段代码包裹时,这段代码就是一段同步代码块,在线程运行到同步代码块时,首先会尝试获取synchronized上的锁对象,如果获取不到则会阻塞,直到获取到锁为止。获取到锁时才会执行同步代码块中的代码,当退出同步代码块时,会释放当前持有的锁对象;
所以在同一时间,只能有一个线程进入同步代码块来执行。
内置锁synchronized是可重入锁。
注:在同步代码块中的代码需要尽可能的短,不要将不需要同步的代码放入同步代码块,不要将耗时或者阻塞的操作放入同步代码块,否则性能会大大出现问题;
4)原子更新类
如果需要保持原子性操作的代码是对某些变量的更新,那么可以使用基于cas操作的原子更新类来实现。
4.1.4、问题解决
1)加锁解决
public class AtomicDemo {
public static void main(String[] args) throws Exception{
test(100, 1);
test(100, 10);
test(100, 100);
test(100, 1000);
test(100, 10000);
test(100, 100000);
}
public static void test(int threadCount, int incrementCount) throws Exception{
Increment increment = new Increment();
Thread[] threads = new Thread[threadCount];
for(int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> {
for(int j = 0; j < incrementCount; j++) {
increment.increment();
}
});
threads[i] = thread;
thread.start();
}
for(int i = 0; i < threadCount; i++) {
threads[i].join();
}
System.out.println(increment.getI());
}
}
class Increment{
private int i;
public void increment() {
synchronized (this) {
i++;
}
}
public Integer getI() {
return i;
}
}
2)原子更新类
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
public static void main(String[] args) throws Exception{
test(100, 1);
test(100, 10);
test(100, 100);
test(100, 1000);
test(100, 10000);
test(100, 100000);
}
public static void test(int threadCount, int incrementCount) throws Exception{
Increment increment = new Increment();
Thread[] threads = new Thread[threadCount];
for(int i = 0; i < threadCount; i++) {
Thread thread = new Thread(() -> {
for(int j = 0; j < incrementCount; j++) {
increment.increment();
}
});
threads[i] = thread;
thread.start();
}
for(int i = 0; i < threadCount; i++) {
threads[i].join();
}
System.out.println(increment.getI());
}
}
class Increment{
private AtomicInteger i = new AtomicInteger(0);
public void increment() {
i.incrementAndGet();
}
public Integer getI() {
return i.get();
}
}
控制台打印结果:
100
1000
10000
100000
1000000
10000000
4.2、内存可见性
4.2.1、问题描述
所有的变量在主内存中保存着,所有的线程在操作变量时,都会保存一份副本在自己的工作内存中。线程中所有变量操作必须在自己的工作内存中执行,不能直接从主内存中读写,不同线程之间无法访问其他线程的工作内存中的变量。所以说线程一修改了自己工作内存中变量的值,其他线程是无法观察到的。
4.2.2、代码重现
public class VolatileDemo {
public static void main(String[] args) throws Exception{
RunningState state = new RunningState();
Thread work = new Thread(() -> {
while (state.isRunning()) {
// 逻辑代码
}
System.out.println("线程停止");
});
work.start();
Thread.sleep(100);
state.setRunning(false);
}
}
class RunningState{
private boolean isRunning = true;
public boolean isRunning() {
return isRunning;
}
public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}
}
执行结果:
work线程无法停止
4.2.3、解决方案
1)synchronized加锁
Java语言规定,一个线程在加锁前,会从主内存中刷新一遍变量的值到自己的工作内存中;在释放锁之后,会将工作内存中的变量刷新回主内存中。
2)volatile关键字
被volatile修饰的变量,在读取他的值的时候,会强制从主内存中读取;在对他进行修改的时候,会强制将他写入主内存中。
底层:在读取volatile修饰的变量时,会在读取之前加入load屏障指令,在写入变量之后会加入store屏障指令。
4.2.4、问题解决
(1)synchronized加锁
public class VolatileDemo {
public static void main(String[] args) throws Exception{
RunningState state = new RunningState();
Thread work = new Thread(() -> {
while (state.isRunning()) {
// 逻辑代码
}
System.out.println("线程停止");
});
work.start();
Thread.sleep(100);
state.setRunning(false);
}
}
class RunningState{
private boolean isRunning = true;
public synchronized boolean isRunning() {
return isRunning;
}
public synchronized void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}
}
(2)volatile关键字
public class VolatileDemo {
public static void main(String[] args) throws Exception{
RunningState state = new RunningState();
Thread work = new Thread(() -> {
while (state.isRunning()) {
// 逻辑代码
}
System.out.println("线程停止");
});
work.start();
Thread.sleep(100);
state.setRunning(false);
}
}
class RunningState{
private volatile boolean isRunning = true;
public boolean isRunning() {
return isRunning;
}
public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}
}
执行结果:
work线程可以停止,并打印“线程停止”。
4.3、指令重排序
4.3.1、问题描述
在处理器(cpu)执行代码的时候,cpu会对执行顺序进行优化,可能代码1写在代码2之前,但是处理器会先执行代码2再执行代码1,可能会提高cpu的执行效率,这就是指令重排序问题。
指令重排序包括编译器重排序和处理器重排序。
指令重排序会遵守一些原则:1、存在相互依赖的代码不会重排序,比如先读取变量a,在对变量a+1,这两个操作就不会重排序;2、重排序后的代码保证within-thread as-if-serialsemantics,就是重排序后的代码在单线程下执行与正确顺序执行的结果一致,表现为串行。
指令重排序只会保证在单线程下没问题,多线程下就可能会有安全性问题。
4.3.2、代码重现
指令重排序问题用代码重现比较难,从网上找了一个案例:
public class SimpleHappenBefore {
public static void main(String[] args) throws InterruptedException {
for(int i=0;i<500000;i++){
SimpleHappenBefore.State state = new SimpleHappenBefore.State();
ThreadA threadA=new ThreadA(state);
ThreadB threadB=new ThreadB(state);
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
static class ThreadA extends Thread{
private final SimpleHappenBefore.State state;
ThreadA(SimpleHappenBefore.State state) {
this.state = state;
}
public void run(){
state.a=1;
state.b=1;
state.c=1;
state.d=1;
}
}
static class ThreadB extends Thread{
private final SimpleHappenBefore.State state;
ThreadB(SimpleHappenBefore.State state) {
this.state = state;
}
public void run(){
if(state.b==1 && state.a==0){
System.out.println("b=1");
}
if(state.c==1 && (state.b==0 || state.a == 0)){
System.out.println("c=1");
}
if(state.d==1 && (state.a==0 || state.b==0 || state.c==0)){
System.out.println("d=1");
}
}
}
static class State {
public int a = 0;
public int b = 0;
public int c = 0;
public int d = 0;
}
}
执行结果:
控制台上会输出b=1或者c=1或者d=1
4.3.3、解决方案
1)Synchronized加锁
synchronized加锁可以保证:1)在synchronized同步代码块之前的代码不会和同步代码块中、后的代码进行重排序;2)在synchronized同步代码块之后的代码不会被重排序到同步代码块中和前。
也就是在执行synchronized同步代码块之前,他前面的代码必须已经执行完毕;在退出同步代码块之前,同步代码块内的代码必须执行完毕。
2)volatile关键字
volatile关键字可以保证:1)在写volatile关键字修饰的变量之前的代码不会被重排序到写之后;2)在读volatile关键字修饰的变量之后的代码不会被重排序到读之前;3)写volatile关键字修饰的变量不会被重排序到读volatile关键字修饰的变量之后。
3)final关键字
https://www.infoq.cn/article/java-memory-model-6/
final关键字保证的指令重排序很局限,就是说他只能保证几种特定情况下的指令不会重排序,只有特定的几种情况。
1)final写:在构造函数中对final域的写操作不会被重排序到把构造函数构造出来的对象引用赋值给一个引用变量之后。
2)final读:在对一个对象的final域的初次读操作不会被重排序到读取该对象引用之前。
由于我们之前说cpu在重排序时,会保证依赖关系,在一个对象还没读取到的时候,肯定不会去读这个对象的成员变量,但是在某些处理器上,会重排序,比如alpha处理器,这条规则也就是对这个处理器的优化。
3)final写(final修饰的是引用变量):在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
上述三个保证final修饰不被重排序的前提是:this对象没有在构造函数中被引用溢出。(也不是这样说,就是说this如果在构造函数中被引用溢出了,那么前面的保证就没意义了,因为前面的保证都与构造函数构造结束有关,如果在构造结束之前this就溢出了,那就不能保证读取的final变量的正确性了)。
4.4、安全性问题总结
安全性问题三种:原子性操作、内存可见性、指令重排序
解决方法 问题 | synchronized | volatile | final |
原子性操作 | √ | 部分 | 部分 |
内存可见性 | √ | √ | 变量可以 |
指令重排序 | √ | 部分 | 部分 |
4.5、线程安全的类
如果一个类在多线程下使用,不会产生线程安全问题,那么这个类就是线程安全的类。
我们如何保证我们写的类是线程安全的类呢?
(1)封装类中所有字段的访问方式
把类中所有字段的权限修饰符改为private类型,如果外部需要访问这些字段,则提供这些字段的getter/setter方法,这样我们可以对这些方法进行同步控制了。
(2)找出类中所有的共享可变字段
如果类中对外提供了获取这个字段的getter或其他方法,则这个字段是共享字段;
如果类中对外提供了设置这个字段的setter或其他方法,则这个字段是可变字段。
对于引用类型变量,改变引用类型对象中的字段,也算是对这个字段的改变。
(3)对所有操作(包括获取和修改)共享可变变量的方法进行同步控制
(A)锁控制
对某一个共享可变变量的获取和修改的所有方法加锁,并且使用同一把锁。
(B)volatile控制
锁是一种重量级解决方案,开销大,在某些特殊的场景下,我们可以使用volatile关键字来保证线程安全性。这些场景需满足以下情况:
a)对变量的写入不依赖当前值
b)该变量不会与其他变量组成不变性条件
例如:
public class VolatileDemo02 {
private volatile int i;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
}
(4)注意不变性条件
类中的两个或多个变量的数据之间是存在关系的,在对存在关系的多个变量执行写入或者读取操作时,需要保证他们的关系不会被破坏。
保证手段:对这些存在关系的变量的读取和写入都需要加锁保证关系不会被破坏。
(5)避免构造函数的this引用溢出
this引用溢出是指:在构造方法完成之前,将当前对象的this引用赋值到了一个变量上,这个变量在其他线程中还可以访问,那么其他线程访问这个变量时,就等于在访问要构造的对象,但是这个对象还没有构造完毕呢,所以会出现从这个变量上读取的信息有误,造成安全性问题。
this引用溢出第一种是,直接在构造方法中将this赋值给一个变量;另一种是在构造方法中存在内部类(比如创建了一个线程,使用匿名内部类传入了一个任务)。在《java编程思想-第4版》192页指出,"当某个外部类的对象创建了一个内部类对象时,此内部类对象必定秘密地捕获了一个指向那个外部类对象的引用"。也就说,这个内部类中有这个构造方法正在构造对象的this引用,造成了this引用溢出。
this引用溢出是一个非常危险的操作,他是极度不安全的,我觉得它也属于一种线程安全类的问题,但是它属于一个代码上写法的问题,需要靠程序员写代码自己避免。