Java 多线程(3)---- 线程的同步(上)

时间:2022-12-22 23:25:10

前言

我们在前面两篇文章中分别看了一下 Java 线程的一些概念、用法和对于线程控制(开始、暂停、停止)等,并对其中的一些易错点进行了总结,如果你是对这些概念还是还不是太熟悉,建议先看一下前面的文章:Java 多线程(1)— 初识线程Java 多线程(2) — 线程的控制。这篇文章我们来继续讨论 Java 多线程 — 线程的同步。

Java 内存模型

在开始介绍线程同步之前,我们必须要对 Java 中的内存模型(这里是针对线程的角度上来看)有一个大概的理解。这个是理解后面的内容的基础。
我们先从计算机的角度来看这个问题:我们都知道,计算机的 CPU 是整个计算机的 “心脏”,也是衡量一台计算机性能的一个重要指标,其 CPU 运算速度越快,相对来说这台计算机性能就越好。也正是因为计算机 CPU 的运算速度非常快,而相对来说主内存(可以理解成计算机的内存条)的读取和写入速度就很慢了,那么如果不另外采取手段弥补两者的速度差距,那么 CPU 再好的计算机的性能也会被内存的速度所影响。为了解决这个问题,人们在计算机 CPU 和内存之间又加了一个高速缓存 器件,其相比计算机主内存的特点是:读写速度比内存快 10 以上,接近于 CPU 的速度,但是其储存空间很小。我们可以用一张图来看一下 CPU、高速缓存和内存之间的关系:
Java 多线程(3)---- 线程的同步(上)
我们再从 Java 线程角度上来看 Java 的内存模型:
从 Java 线程角度,我们把 Java 内存模型分为主内存和每条线程私有的工作内存。也就是说,从这个角度上看,Java 内存模型就只剩下两个类型:主内存、线程工作内存。和计算机的内存模型类似,我们也可以通过一张图来理解下 Java 线程工作内存和主内存之间的关系:
Java 多线程(3)---- 线程的同步(上)
从上图中我们可以看到:
1、Java 线程只能直接对其的私有工作内存进行读取和写入数据操作,而不能对主内存直接进行读取和写入操作。
2、主内存对所有的 Java 线程都可见,即所有的 Java 线程都可以通过其工作内存来间接的修改主内存中的数据。
3、线程的工作内存只对其对应的 Java 线程可见,不同的 Java 线程不共享其工作内存。

而在图中,线程私有工作内存和主内存之间又可以进行互相的读取和写入操作,然而这里的 “读取/写入” 操作的描述其实并不严谨,因为 Java 线程工作内存和主内存之间的交互需要遵循 Java 规定的交互协议,这个交互协议定义了 8 种原子性的操作来完成线程工作内存和主内存的交互,但是在这里我们并不需要去深入的了解这 8 中操作的原理,我们只需要知道这些概念并且知道线程私有的工作内存可以通过某些 Java 底层已经实现的操作来和主内存进行数据的交互就可以了。

现在我们知道,如果一个 Java 线程要修改主内存中的某个数据,它必须经过下面几个步骤:
1、这个线程的私有工作内存读取在主内存中要修改的那个数据值并且拷贝一份副本留在该线程的工作内存中;
2、线程执行相关代码在其工作内存中修改这个从主内存拷贝过来的副本值;
3、该线程的工作内存将修改后的值写入到主内存中。

假设现在在主内存中有一个 int 类型的变量 x 值为 10,如果我想通过线程将这个变量 x 得值改为 1,根据上面的描述,会经过哪些过程?来看一张图:
Java 多线程(3)---- 线程的同步(上)

原子性

了解了线程角度上的 Java 的内存模型之后,我们再来看一下原子性的概念,我们很多情况下都可能听到 原子性 这个词。
在自然界中,原子是构成物质的基本单位(当然电子等暂且不论),所以原子的意思代表着——“不可分的最小单位”。
在操作系统中的定义是:对于一个操作来说,如果执行它,那么在执行过程中不会被其他因素打断直到完成这个操作,否则这个操作就不执行。我们称这个操作具有原子性。
我们在 Java 中常用的 a = 1; 操作通常是具有原子性的,而类似于 a += 1;a++; 等操作就不具有原子性。为什么会有这个结论呢?要深入理解这个问题,我们需要从它们的字节码入手,我们可以创建一个 Java 类 Test.java

public class Test {
    static int a;

    public static void decrease() {
        a--;
    }

    public static void setA(int t) {
        a = t;
    }

    public static int getA() {
        return a;
    }
}

我们用 javac 指令来编译这个类文件(注意:在使用这些 Java 指令之前,必须保证你的计算机已经将 JDK 中 bin 目录加入了环境变量,否则需要使用指令的绝对路径),具体格式为 javac 类文件的绝对路径 ,编译完成后我们在源文件的相同路径下会得到一个同名的 class 文件 test.class 。接下来我们再用 javap -v class文件的绝对路径 来得到对应的字节码,结果如下:
Java 多线程(3)---- 线程的同步(上)
我们可以看到类中的方法中有 getstaticiconst_1isubputstaticireturnreturn 指令,有点类似于汇编指令。我们来看一下它们大概的意思:

getstatic 指令为从静态储存区取出变量的值并且压入操作栈顶
iconst_1 指令为将整形常量 1 压入操作栈顶
isub 指令为从栈中取出两个整形变量将相减的结果压入操作栈顶
putstatic 指令为从操作栈顶中取出变量的值并将变量值写入主内存中
iload_0 指令为将局部变量(这里即为 setA 方法的第一个参数)压入到操作栈顶
ireturn 指令为方法结束并返回从操作栈顶取出的 int 类型值
return 即为方法的结束返回指令

我们可以看到在 setA 方法的字节码中对变量 a 进行改变的字节码只有 putstatic ,所以我们可以把它理解成原子性的。同样的,在 getA 方法中取出变量 a 的字节码也只有 ‘getstatic’ ,因此我们也可以把它理解成原子性的。但是对于 a--; 我们可以看到其操作的字节码是这么一段:

getstatic
iconst_1
isub
putstatic

很明显 a--; 转化成字节码后要进行多步操作,所以其在没有另加同步措施干预的情况下不具有原子性。对于 a += 1;a++; 等操作也是同样的道理,相信你也可以通过字节码来分析这些操作。

线程并发带来的问题

有了上面的知识之后,我们再来看一下我们平常经常遇到的多线程并发的奇怪问题:
1、卖车票问题:假设有 10 张火车票,现在有 5 个线程模拟 5 个窗口卖票,我们用 Java 代码模拟这个过程:

/** * 售卖火车票的测试类 */
public static class SellTickets {
    static int tickets = 10; // 10 张火车票

    protected static void sell() {
        System.out.println(Thread.currentThread().getName() + "卖出了第 " + tickets-- + " 张票");
    }

    public static void startSell() {
        // 开启 5 个线程售票
        for (int i = 0; i < 5; i++) {
            new Thread("窗口" + (i+1)) {
                @Override
                public void run() {
                    while (tickets > 0) {
                        sell();
                        try {
                            Thread.sleep(1000);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }.start();
        }
    }
}

public static void main(String[] args) {
    SellTickets.startSell();
}

这个代码应该不用解释了,就是开启了 5 个线程来模拟 5 个窗口一起卖票,为了更好的观察结果,我们每卖一张票就让卖票的线程休眠一秒让出 CPU。我们来看看结果:
Java 多线程(3)---- 线程的同步(上)
我们从图中可以看到这个卖票的结果显然不正确,我们知道 sell 方法中改变 tickets 变量的代码就是 tickets--; 操作 。我们已经知道这个操作不具有原子性。因此对于上图结果中两个窗口卖出了同一张车票的一种可能的情况是:
一开始先是线程 1 得到 CPU 资源开始执行,在执行 sell 方法的时候通过 getstatic 指令将主存中 tickets 的值拷贝一份副本到线程 1 的工作内存中,此时这个拷贝的副本的值是 10,线程 1 还没来得及进行下一步操作时线程 2 又得到了 CPU 资源,同样的线程 2 通过 getstatic 指令将主存中 tickets 的值拷贝一份副本到线程 2 的工作内存中,这个拷贝的副本的值也是 10(此前线程 1 还没来得及修改这个值就让出了 CPU 资源并陷入阻塞状态),我们知道 tickets-- 操作是先用 tickets 的值,再将其减一,那么在输出的时候就有可能出现上图结果中两个窗口卖出同一张票的情况。

我们再看一下出现上图结果中卖出第 0 张票的异常情况的一种可能情况:
假设当前主内存中 tickets 的值为 1,根据图中结果,线程 4 此时得到 CPU 资源并执行 sell 方法,不巧的是当线程 4 正在执行 System.out.println(...) 代码并且在请求输出流的时候被阻塞了,那么这时线程 2 得到了 CPU 资源,此时主内存中的 tickets 变量值仍然为 1,因此其 run 方法中的循环条件仍成立,线程 2 开始执行sell 方法。
不巧的事又发生了:线程 2 正在执行 System.out.println(...) 代码并且在请求输出流的时候被阻塞了,
这时线程 4 又得到了 CPU 资源并且得到了输出流资源,那么线程 4 会先从主内存中取出 tickets 的值、打印出车票信息(此时打印的结果为第 1 张车票)、将 tickets 的值减一并且将修改后的值(tickets 修改后值为 0)重新写入主内存,线程 4 结束运行。
之后线程 2 得到 CPU 资源和输出流资源,同样的,线程 2 会先从主内存中取出 tickets 的值(此时已经为 0)、打印出车票信息(此时打印的结果为第 0 张车票 )、将 tickets 的值减一并且将修改后的值(tickets 修改后值为 -1)重新写入主内存,线程 2 结束运行。也就产生了窗口 2 卖出第 0 张车票的结果。

对于这两个现象我们已经分析完了,不知道小伙伴们有没有发现一个问题:在这里我们一开始只定义了 10 张车票(tickets 初始值为 10),以计算机的执行速度,只要某个线程一获得 CPU 资源,那么这个线程几乎瞬间就可以将所有的车票卖完,根本就不需要等线程调度器再去调度其他的线程来卖车票。 那为什么还会出现多个线程轮流卖车票的情况呢?可能此时的你已经已经反应过来了:在每个线程的 run 方法中不是调用了 Thread.sleep(1000); 方法吗,这个方法会让当前执行的线程让出 CPU 资源并且陷入休眠,每卖出一张车票,这个线程就会休眠 1 秒。所以当然会有多个线程轮流卖车票的现象。
我得承认这个说法相当正确,但是我们反过来想:如果我们把每个线程中的 run 方法中的 Thread.sleep(1000) 去掉,那么就会出现所有的车票都是由一个线程卖出的现象吗?我们不妨试试:

/** * 售卖火车票的测试类 */
public static class SellTickets {
    static int tickets = 10; // 10 张火车票

    static String name = "";
    static String firstName = "";
    static boolean isSameThread = true;

    protected static void sell() {
        System.out.println(Thread.currentThread().getName() + "卖出了第 " + tickets-- + " 张票");
    }

    public static void startSell() {
        // 开启 5 个线程售票
        for (int i = 0; i < 5; i++) {
            new Thread("窗口" + (i+1)) {
                @Override
                public void run() {
                    while (tickets > 0) {
                        sell();
                    }
                }
            }.start();
        }
    }
}

public static void main(String[] args) {
    SellTickets.startSell();
}

结果:
Java 多线程(3)---- 线程的同步(上)
遗憾的是,答案并不是我们想要的,虽然说线程 1 卖出了大部分车票,但是并不是全部。那么导致这个现象的问题是什么?其实也很简单:sell 方法中我们是调用了 System.out.println(...) 方法的,这个方法是进行数据输出的方法(即为 IO 操作),还记得我们在第一篇文章:Java 多线程(1) — 初识线程 中提到的:IO 操作可能会导致线程让出 CPU 进入等待状态吗?所以即使我们去掉了 Thread.sleep(1000) 方法,也不能完全保证所有的车票都会被同一个线程卖出。因为 System.out.println(...) 方法在进行输出的同时也起了 Thread.sleep(1000) 的作用。

不能在子线程中采用输出方法,那么我们怎么检验我们刚刚的结论呢?
我们可以这样想:在线程卖出车票的时候我们并不输出,而是将第一次卖出车票的线程名记录下来,之后每当有线程卖出车票时都和前一个卖出车票的线程名对比,看看是不是同一个线程,当所有的车票都买完之后,我们在主线程输出我们检测结果:

/** * 售卖火车票的测试类 */
public static class SellTickets {
    static int tickets = 10; // 10 张火车票
    static String firstSellName = null;
    static String currentSellName = null;
    static boolean isSameThread = true; // 记录是否是同一个线程将所有的车票卖完

    protected static void sell() {
        System.out.println(Thread.currentThread().getName() + "卖出了第 " + tickets-- + " 张票");
    }

    public static void startSell() {
        // 开启 5 个线程售票
        for (int i = 0; i < 5; i++) {
            new Thread("窗口" + (i+1)) {
                @Override
                public void run() {
                    while (tickets > 0) {
                        tickets--;
                        // 第一次卖出车票
                        if (firstSellName == null) {
                            firstSellName = currentSellName = Thread.currentThread().getName();
                        // 当前卖出车票的线程和之前卖出车票的线程不是同一个线程
                        } else if (!currentSellName.equals(Thread.currentThread().getName())) {
                            isSameThread = false;
                            currentSellName = Thread.currentThread().getName();
                        }
                    }
                }
            }.start();
        }
        // 当当前活动的线程数大于一(即为存在卖车票的子线程时),主线程应该礼让出 CPU 资源,
        // 等待所有的子线程结束之后再进行结果输出
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println("第一次卖出车票的线程名:" + firstSellName + "\n最后一次卖出车票的线程名:"
                            + currentSellName + "\n是否只有一个线程卖出所有的车票:" + isSameThread);
    }
}

public static void main(String[] args) {
    SellTickets.startSell();
}

结果:
Java 多线程(3)---- 线程的同步(上)
当前结果确实证明了我们的猜想,那么对于所有的结果都是这样吗?这个我不敢保证,随着 tickets 的值逐渐增大的时候,情况就完全不一样了,小伙伴们可以将 tickets 的值设为 10000,试试,这是我随机截的一张将 tickets 值改为 10000 的运行结果图:
Java 多线程(3)---- 线程的同步(上)
可以看到这 10000 张车票并不是由同一个线程卖出的。原因也很简单:随着 tickets 值的增大,线程 run 方法需要执行的循环次数就越多,而对于每个线程来说,其只在一个很小的时间片段内可以使用 CPU 资源, 如果在这个时间片段内其 run 方法没有执行完成,线程调度器就会使当前线程让出 CPU 资源。 因此可能在某个线程 run 方法执行到一部分的时候,线程调度器就打断了这个线程,使其让出 CPU 资源,并且调度其它线程得到 CPU 资源并执行。当然,也有可能线程调度器还是调度当前线程获得 CPU 资源,具体哪个线程得到 CPU 资源取决于线程调度的调度。

我们再来看一个常见的多线程并发导致的问题:开 10 个线程,每个线程对同一个变量递增 10000 次,最后打印结果。:

/** * 多线程累加测试类 */
public static class ThreadsAddTest {
    static int sum = 0;

    private synchronized static void add() {
        sum++;
    }

    public static void startAdd() {
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        add();
                    }
                }
            }.start();
        }
        // 当还存在子线程的时候,证明子线程累加还未结束,主线程应该让出 CPU 资源,
        // 直到子线程累加结束后再进行打印 sum 的值
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println("sum: " + sum);
    }
}


public static void main(String[] args) {
    ThreadsAddTest.startAdd();
}

结果:
Java 多线程(3)---- 线程的同步(上)
这个结果是不确定的,每次运行都会产生一个新的 sum 值, 但是这个值一定会小于 100000。至于原因就留给小伙伴们自己思考了(可以从 sum++; 操作是否具有原子性进行思考)。


好了。Java 多线程第三篇就到这里了,关于上面提出的问题的解决办法会在下一篇文章中给出。
对于一些 Java 字节码中的指令的意思,可以参数这篇文章:
http://www.cnblogs.com/ggzwtj/archive/2012/03/26/2418634.html

如果博客中有什么不正确的地方,还请多多指点。如果这篇文章对您有帮助,请不要吝啬您的赞,欢迎继续关注本专栏。

谢谢观看。。。