在进行多线程开发时,我们时常会遇到多个线程同时操作(读/写)同一个数据的情况。这时,我们通常会遇到该数据经过多个线程的操作后无法得到我们想要的结果,这就是线程的安全问题。
实例
第一组
我们想象一个买票的系统,有两个通道,也就是线程同时开放买票,我们需要统计卖出去的票数。我们可以定义一个count变量,在两个线程中分别循环递增100000次,最后再输出count,理论上得到的值应该是200000。下面来看代码:
java
public class Lock extends Thread {
static int count = 0;
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
count++;
}
}
public static void main(String[] args) {
Lock l = new Lock();
l.start();
ThreadDemo2 td2 = new ThreadDemo2();
td2.start();
try {
l.join();
td2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("已卖出:"+count);
}
}
class ThreadDemo2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
Lock.count++;
}
}
}
但是结果非常随机,和我们理论的结果相差不少:

第二组
我们在一个线程中定义一个静态的boolean变量并初始化为false,写一个死循环,判断当boolean为true时才跳出死循环。然后,在另外一个线程中写一个修改,将该布尔型变量改为true,并加上对应的输出语句。
需要注意的是操作的变量需要是同一个,所以要求在主函数创建新的对象后需要进行一个赋值的操作,保证是同一个对象。下面看代码:
java
public class ThreadDemo extends Thread{
boolean isn=false;
public void run(){
System.out.println("线程A启动!");
while (true){
if(isn){
break;
}
}
System.out.println("线程A结束~");
}
}
class ThreadDemo1 extends Thread{
ThreadDemo tdr;
@Override
public void run() {
System.out.println("线程B启动!");
tdr.isn=true;
System.out.println("线程B结束,已做更改~");
}
}
class Main{
public static void main(String[] args) {
ThreadDemo td=new ThreadDemo();
ThreadDemo1 td1=new ThreadDemo1();
td1.tdr=td; //赋值,保证操作的是同一个isn变量
td.start();
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
td1.start();
}
}
但结果是程序运行卡住,始终没有跳出死循环:

问题分析
要想弄清为什么会出现进程安全问题,我们就得先了解Java底层JVM的内存模型,下面我们来借助一张示意图来理解:

一个进程的内存包括三部分:堆内存、本地变量表、栈内存。
案例一分析
如上图所示,当我们定义一个static变量时,在整个内存中就只存在一份,位于进程的堆内存中。当我们的线程要去操作这个变量时(循环递增),线程就会从堆内存中读取,并在本地变量表中创建一个副本,然后到栈内存中利用这个副本的数据执行该线程需要执行的代码命令,结束之后再写回堆内存的静态变量中。
那么,我们的程序无法达到预期的结果的原因也就显而易见了:两个线程可能同时读取了count变量为0时的数据,其创建的副本都为0,进行递增操作后变为1写回,那么堆内存中的count会变为1,但实际上已经进行了两次循环,所以我们看到的结果就会比预期的结果小。
案例二分析
我们通过调试语句已经证明:布尔变量的值已经变成了true,但是循环并没有被跳出,所以问题只能出现在执行循环的线程并没有按预期重新读取更新之后的布尔变量,而是依然用最初的变量副本,导致循环一直不能跳出。这就是线程安全中的信息不同步问题。
问题解决
明确了问题之后,我们就要研究解决问题的方法。下面,我们来介绍保证线程安全的一些方法。
1、锁(synchronized)
synchronized是Java的内置锁,被称为同步锁。
作用
锁的功能是锁定某个代码块,当前线程在执行这个代码块时,其他的所有线程都会阻滞,等待这个代码块执行完毕后,该线程才会与其他的线程重新开始公平竞争。
使用方法
这个关键字需要一个参数,该参数的要求是:必须是一个对象,对象必须是当前所有线程都可以访问唯一对象,一般的操作是创建一个static final的Object类的对象,然后作为参数传入。
解决问题
我们将两个循环都加上锁:
java
public class Lock extends Thread {
static int count = 0;
static final Object obj = new Object();
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (obj) { //加上锁
count++;
}
}
}
public static void main(String[] args) {
Lock l = new Lock();
l.start();
ThreadDemo2 td2 = new ThreadDemo2();
td2.start();
try {
l.join();
td2.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("已卖出:" + count);
}
}
class ThreadDemo2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (Lock.obj){ //加上锁
Lock.count++;
}
}
}
}
这样,结果就能按预期输出了!(也可以创建更多的对象进行测试)

2、volatile关键字
volatile是Java的一个类型修饰符,用于类型声明之前。
作用
被volatile修饰的变量在多个线程中具有可见性,当一个线程对该数据进行修改后,另一个线程也会随即更新。换句话讲,被该关键字修饰的变量在使用前,都会检查该变量在堆内存中是否被更新,以确保使用最新的数据。
解决问题
案例二中出现的问题就是执行循环的线程没有及时检查堆内存中布尔变量有没有被修改,从而无法更新数据,导致循环无法跳出。因此,我们只需要给该布尔变量加上volatile修饰即可。
java
volatile boolean isn=false;
这样,结果就能按预期输出了!

3、join方法
join方法是Thread类中的方法,是控制代码执行顺序的工具。
当我们继承了Thread的类的对象调用join方法时,程序就会阻滞在此,等待该线程执行完毕后再执行接下来的代码。这样,我们就可以控制线程启动执行和其他代码执行的先后顺序,并且比调用sleep方法更加精确,不会出现延迟时间过短或者过长的现象,是程序更加高效。