Java多线程编程核心技术(第3版)
上QQ阅读APP看书,第一时间看更新

1.2.8 实例变量共享导致的“非线程安全”问题与相应的解决方案

自定义线程类中的实例变量针对其他线程可以有共享与不共享之分,这在多个线程之间交互时是很重要的。

图1-18 不共享数据的情况

1.不共享数据的情况

不共享数据的情况如图1-18所示。

下面通过一个示例来看数据不共享情况。

创建实验用的Java项目,名称为t3,MyThread.java类代码如下:


public class MyThread extends Thread {
private int count = 5;

public MyThread(String name) {
    super();
    this.setName(name);         // 设置线程名称
}

@Override
public void run() {
    super.run();
    while (count > 0) {
        count--;
        System.out.println("由 " + this.currentThread().getName()
            + " 计算,count=" + count);
    }
}
}

运行类Run.java代码如下:


public class Run {
public static void main(String[] args) {
    MyThread a=new MyThread("A");
    MyThread b=new MyThread("B");
    MyThread c=new MyThread("C");
    a.start();
    b.start();
    c.start();
}
}

图1-19 不共享数据

运行结果如图1-19所示。

由图1-19可以看到一共创建了3个线程,每个线程都有各自的count变量,自己减少自己的count变量的值,这样的情况就是变量不共享,此示例并不存在多个线程访问同一个实例变量的情况。

如果想实现3个线程共同去对1个count变量进行减法操作,代码该如何设计呢?

2.共享数据的情况

共享数据的情况如图1-20所示。

图1-20 共享数据的情况

共享数据的情况就是多个线程可以访问同一个变量,如在实现投票功能的软件时,多个线程同时处理同一个人的票数。

下面通过一个示例来看数据共享情况。

创建t4测试项目,MyThread.java类代码如下:


public class MyThread extends Thread {

private int count=5;
    
@Override
public void run() {
    super.run();
        count--;
        // 此示例不要用while语句,会造成其他线程得不到运行的机会
        // 因为第一个执行while语句的线程会将count值减到0
        // 一直由一个线程进行减法运算
        System.out.println("由 "+this.currentThread().getName()+" 计算,count="+
            count);
}
}

运行类Run.java代码如下:


public class Run {
public static void main(String[] args) {
    MyThread mythread=new MyThread();

    Thread a=new Thread(mythread,"A");
    Thread b=new Thread(mythread,"B");
    Thread c=new Thread(mythread,"C");
    Thread d=new Thread(mythread,"D");
    Thread e=new Thread(mythread,"E");
    a.start();
    b.start();
    c.start();
    d.start();
    e.start();
}
}

图1-21 共享变量值重复,出现线程安全问题

运行结果如图1-21所示。

从图1-21中可以看到,线程A和B输出的count值都是3,说明A和B同时对count进行处理,产生了“非线程安全”问题。而我们想要得到的输出结果却不是重复的,应该是依次递减的。

出现非线程安全的情况是因为在某些JVM中,count--的操作要分解成如下3步(执行这3个步骤的过程中会被其他线程所打断):

1)取得原有count值;

2)计算count-1;

3)对count进行重新赋值。

在这3个步骤中,如果有多个线程同时访问,那么很大概率会出现非线程安全问题,得出重复值的步骤如图1-22所示。

图1-22 得出重复值的步骤

A线程和B线程对count执行减1计算后得出相同值4的过程如下:

1)在时间单位为1处,A线程取得count变量的值5;

2)在时间单位为2处,B线程取得count变量的值5;

3)在时间单位为3处,A线程执行count--计算,将计算后的4值存储到临时变量中;

4)在时间单位为4处,B线程执行count--计算,将计算后的4值也存储到临时变量中;

5)在时间单位为5处,A线程将临时变量中的值4赋值给count;

6)在时间单位为6处,B线程将临时变量中的值4也赋值给count;

7)最终结果就是A和B线程都得到相同的计算结果为4,非线程安全出现了。

i--操作会出现非线程安全问题,同理i++操作也有同样效果,请自行测试:创建10个线程,每个线程使用for循环对同一个对象的同一个实例变量A进行+1操作,循环1000次,但最终A的值并不是10000。

其实在JVM层面,i++操作对应的字节码需要执行4步,创建测试类代码如下:


public class Test {
    static int i = 100;

    public static void main(String[] args) throws InterruptedException {
        i++;
    }
}

在CMD中执行如下命令:


C:\Users\Administrator\eclipse-workspace\test1\bin\test2>javap -c -v Test.class

生成的字节码指令如下:


getstatic    #获取static变量
iconst_1        #产生整数1
iadd    #对static变量进行加1操作
putstatic       #对static变量进行赋值

执行这4个步骤时是允许被打断的,所以多个线程执行i++操作的结果是不正确的。

i--操作对应的字节码如下:


getstatic
iconst_1
isub
putstatic

出现非线程安全的情况是多个线程操作同一个对象的同一个实例变量,导致值不准确。

i++或i--操作其实就是典型的销售场景,5个销售员,每个销售员卖出一个货品后不可以得出相同的剩余数量,必须在当前销售员卖完一个货品后,其他销售员才可以在新的剩余物品数上继续减1操作,这时就需要在多个线程之间进行同步操作,也就是按顺序排队的方式进行减1,更改代码如下:


public class MyThread extends Thread {
private int count=5;
@Override
synchronized public void run() {
    super.run();
        count--;
        System.out.println("由 "+this.currentThread().getName()+" 计算,count="+
            count);
}
}

重新运行程序,便不会出现值一样的情况了,如图1-23所示。

图1-23 方法调用被同步

通过在run方法前加入synchronized关键字,使多个线程在执行run方法时,以排队的方式进行处理。一个线程在调用run方法前,需要先判断run方法有没有上锁,如果上锁,说明有其他线程正在调用run方法,必须等其他线程调用结束后才可以执行run方法,这样也就实现了排队调用run方法的目的,实现了按顺序对count变量减1的效果。虽然i--操作仍被划分成3个步骤,但在执行这3个步骤时并没有被打断,呈“原子性”,所以运行结果是正确的。

使用synchronized关键字修饰的方法称为“同步方法”,可用来对方法内部的全部代码进行加锁,而加锁的这段代码称为“互斥区”或“临界区”。

当一个线程想要执行同步方法里面的代码时,它会首先尝试去拿这把锁,如果能够拿到,那么该线程就会执行synchronized里面的代码。如果不能拿到,那么这个线程就会不断尝试去拿这把锁,直到拿到为止。

例如,创建10个线程,每个线程使用for循环对同一个对象的同一个实例变量A进行+1操作,循环1000次,但最终A的值并不是10000。

如果对这个实例使用synchronized关键字,则最终结果100%都是10000。