当多个线程并发的读写相同的数据,可能出现数据错误,从而导致线程安全问题。

public class Main {

    private int count = 0;

    public void increase() {
        this.count++;
    }

    public int getCount() {
        return this.count;
    }

}

如上代码,如果多个线程并发的调用 increase 方法,就可能引起线程安全问题。

虽然自增运算符看上去就一行代码,但是实际会被解释为三条指令:读取 count 的值 -> 值 +1 -> 将新值写入 count 。这样在多线程的时候执行情况就类似如下了:

    Thread1             Thread2
    r1 = count;         r3 = count;
    r2 = r1 + 1;        r4 = r3 + 1;
    count = r2;         count = r4;

这样会造成的问题就是 r1r3 读到的值都是 0,最后两个线程都将 1 写入 count,最后 count 等于 1 ,但实际执行了两次自增,期望值应该是 2 才对。

线程安全的三大特性

  • 原子性
    i = 2; // 将 2 写入 i,是原子的,线程安全
    j = i; // 先读取 i,再将其值写入 j,因此不是原子的,线程不安全
    
  • 可见性

从Java的内存模型我们知道所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、写入等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递必须通过主内存来完成。所以当某个线程将自己的工作内存中的变量修改后,在还没有同步到主内存时,或者别的现在还未从主内存中同步最新的变量值时,线程之间就会出现数据不一致的情况,从而引发线程安全问题。

  • 有序性

现代CPU为了提高执行效率可能会对指令进行重排序。

double pi = 3.14;      // A
double r = 1;          // B
double s = pi * r * r; // C

如上代码,A、B是两条独立的语句,而C则依赖于A、B,所以A、B可以重排序,但是C却不能排到A、B的前面。而无论是按 A->B->C 顺序执行,还是按 B->A->C 执行,最终 s 的结果都是 3.14,对于这样的情况不会出现线程安全问题。

publi class Main {
    private int a = 0;
    private bool flag = false;
    
    public void write() {
        a = 2;              // 1
        flag = true;        // 2
    }
    
    public int multiply() {
        if (flag) {         // 3
            return a * a;   // 4
        } else {
            return a;       // 5
        }
    }

}

如上代码,两个线程A、B并发的执行,A线程调用 write 方法,B线程调用 multiply 方法,那么B线程执行完成后得到的返回结果会是什么呢?可能的情况如下:

  • A线程执行完成(无论是否有指令重排),a=2, flag=true,B线程开始,因此返回结果为 4
  • A线程未开始,a=0, flag=false,B线程执行完成(执行语句5),因此返回结果为 0
  • A线程执行到语句1,a=2, flag=false,B线程执行完成(执行语句5),因此返回结果为 2
  • 有指令重排,A线程先执行语句2,a=0, flag=true,B线程执行完成(执行语句4),因此返回结果为 0
  • 还有别的情况就不一一列举了

不同的执行顺序最终执行结果也会不同,因此要保证线程安全也就必须保证指令执行的有序性。

不可变对象

StringIntegerEnumBigInteger 这些不可变类,一旦对象被实例化,其值就不再发生变化(排除通过反射机制修改其私有的成员变量的情况)。既然不存在修改,也就是没有写操作,自然也就没有现成安全问题。

局部变量、线程本地变量

如果全部使用局部变量,那么就不存在线程间共享变量的情况了,自然也就不存在线程安全一说。

线程本地变量也是同理。

volatile

volatile 修饰的共享变量,JVM 提供了两大特性:

    1. 内存可见性即写操作会直接写入主存读操作会直接从主存读取
    2. 禁止指令重排

由于 volatile 无法保证原子性,因此并不能保证绝对的线程安全,其典型的适用场景一般为一写多读,如简单的计数场景。又或者懒汉模式的单例实现。

publi class Singleton{

    private volatile static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    // new一个对象时会分为三步:新建对象 -> 初始化对象 -> 赋值
                    // 如果在并发情况下出现指令重排,可能导致赋值给 instance 的是未初始化的对象
                    // instance 被 volatile 修饰后,会禁止指令重排,从而保证了线程安全
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

CAS

由硬件将 compare and set 封装为一条指令,从而保证了原子性。如 i++ 就可以转换为:

AtomicInteger atomic = new AtomicInteger(0);
atomic.incrementAndGet();
public final incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

compareAndSet 其本质类似于是:

while (true) {
    if (i == 0) { // 比较原始值是否相同
        i = i + 1; // 自增并赋新值
        break;
    }
}

但是 CAS 无法解决 ABA 问题,在对 i 的值与原始值进行比较时,如果 i 的值从 2 变为 0,比较也能通过,然后执行自增并赋新值,但却遗漏了 i=2 的过程。

synchronized

被synchronized关键字修饰的代码块会变成同步代码块,只有线程拿到锁之后才能进入同步代码块执行,因此可以保证线程安全。

public class Main {

    private int count = 0;

    public synchronized void increase() {
        this.count++;
    }

    public synchronized int getCount() {
        return this.count;
    }

}

Lock

使用 synchronized 关键字时,加锁、释放锁都有JVM来控制,如果需要更灵活的锁控制,可使用Lock。

public class Main {

    private int count = 0;
    private Lock lock = new ReentrantLock();

    public void increase() {
        try {
            lock.lock();
            this.count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        try {
            lock.lock();
            return this.count;
        } finally {
            lock.unlock();
        }
    }

}

使用Lock时一定要注意锁的释放,避免出现异常导致锁未释放的情况,因此最好是使用 try/finally 的方式,并在 finally 中释放锁。