[关闭]
@lemonguge 2015-07-10T05:13:55.000000Z 字数 9007 阅读 392

并发性和多线程(二)

Concurrency


Java内存模型规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型。


Java内存模型

如果想设计表现良好的并发程序,理解Java内存模型是非常重要的。Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

Java内存模型内部原理

Java内存模型把Java虚拟机内部划分为线程栈。下面这张图演示了Java内存模型的逻辑视图。

Java内存模型内部原理

每一个运行在Java虚拟机里的线程都拥有自己的线程栈,这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程会在自己的线程栈中来创建本地变量,因此每个线程拥有每个本地变量的独有版本。

所有原始类型(基本数据类型)的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝(基本数据类型属于值传递),但是它不能共享这个原始类型变量自身。

下面这张图演示了调用栈和本地变量存放在线程栈上,对象存放在堆上。

线程栈的本地变量

存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员(成员变量和成员函数)。如果两个线程同时调用同一个对象上的同一个方法,每一个线程都拥有这个本地变量的私有拷贝。下图演示了上面提到的点:

多线程访问同一对象

其中这两个线程栈的一个本地变量(Local Variable 2)指向堆上的一个共享对象(Object 3)。这两个线程分别拥有同一个对象的不同引用,这些引用都是本地变量,因此存放在各自线程的线程栈上。

注意,这个共享对象(Object 3)持有Object2Object4 的引用作为其成员变量(如图中Object3 指向Object2Object4 的箭头)。通过在Object3 中这些成员变量引用,这两个线程就可以访问Object2Object4

以下Java代码会导致上面的内存图:

  1. class MySharedObject {
  2. public static final MySharedObject sharedInstance = new MySharedObject();
  3. public Integer object2 = new Integer(22);
  4. public Integer object4 = new Integer(44);
  5. }
  6. public class MyRunnable implements Runnable{
  7. @Override
  8. public void run() { methodOne(); } // 线程任务
  9. public void methodOne() {
  10. int localVariable1 = 45;
  11. MySharedObject localVariable2 = MySharedObject.sharedInstance;
  12. methodTwo();
  13. }
  14. public void methodTwo() {
  15. Integer localVariable1 = new Integer(99);
  16. }
  17. }

如果两个线程同时开启后执行run()方法,就会出现上图所示的情景。run()方法会调用methodOne()方法,methodOne()会调用methodTwo()方法。

每个线程执行methodOne()都会在它们对应的线程栈上创建一个基本数据类型int的本地变量localVariable1和一个引用数据类型MySharedObject本地变量localVariable2的私有拷贝

methodTwo()创建一个名为localVariable的本地变量,这个成员变量是一个指向一个Integer对象的对象引用。两个线程执行这个方法将会创建两个不同的Integer实例。methodTwo方法创建的Integer对象对应于上图中的Object1和Object5。

硬件内存架构

现代硬件内存模型与Java内存模型有一些不同。理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的,这部分描述了通用的硬件内存架构。下面是现代计算机硬件架构的简单图示:

现代计算机硬件架构的简单图示

一个现代计算机通常由两个或者多个CPU,其中一些CPU还有多核。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程是可能的。每个CPU在某一时刻运行一个线程是没有问题的,这意味着如果你的Java程序是多线程的,在你的Java程序中每个CPU上一个线程可能同时(是并发不是并行)执行。

  1. CPU Registers:每个CPU都包含一系列的寄存器,它们是CPU内存的基础。
  2. CPU Cache Memory:实际上,绝大多数的现代CPU都有一定大小的缓存层,一些CPU还有多层缓存,但这些对理解Java内存模型如何和内存交互不是那么重要。只要知道CPU中可以有一个缓存层就可以了。
  3. RAM:一个计算机还包含一个主存。所有的CPU都可以访问主存,主存通常比CPU中的缓存大得多。

CPU访问寄存器的速度远大于主存,CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。

通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

Java内存模型和硬件内存架构之间的桥接

硬件内存架构没有区分线程栈和堆,所以Java内存模型与硬件内存架构之间存在差异。对于硬件,所有的线程栈和堆都分布在主内中,部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:

内存模型和硬件内存架构

当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。主要包括如下两个方面:

  1. 线程对共享变量修改的可见性
  2. 读、写和检查共享变量时出现竞态条件

共享对象可见性

如果两个或者更多的线程在没有正确的使用volatile声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不可见的。

想象一下,共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中,然后修改了这个对象。只要CPU缓存没有被刷新回主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。下图示意了这种情形:

共享对象可见性

跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改(类似set()方法)为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去。

解决这个问题你可以使用Java中的volatile关键字。volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被立即写回到主存中去

voltile只能保证可见性,并不能保证线程安全。

竞态条件

在上一篇文章中介绍过了竞态条件,在这里将结合示图进行讲述,想象一下,如果线程A读一个共享对象的变量count到它的CPU缓存中,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将count加1,线程B也做了同样的事情。现在count已经被增在了两个,每个CPU缓存中一次。如果这些增加(类似add()方法)操作被顺序的执行,变量count应该被增加两次,然后原值+2被写回到主存中去。

然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将count修改后的版本写回到主存中取,尽管增加了两次,修改后的值仅会被原值大1。如下图所示:

竞态条件

解决这个问题可以使用Java同步块。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile


Java同步块

在临界区中使用适当Java同步块就可以避免竞态条件。

Java中的同步块用synchronized关键字标记,同步块在Java中是同步在某个对象上,一般我们也将那个对象称为锁。在同步块中,我们也把锁称为监视器对象。

所有同步在一个对象上的同步块,同时只能被一个线程(该线程拥有了锁)进入并执行操作,所有其他等待进入该同步块的线程将被阻塞(拥有锁的那个线程也会因为CPU运行切换导致失去执行权而处于临时阻塞状态,但是只有该线程拥有锁,所以也只有它才能处于运行状态;虽然其他线程也会获取到CPU执行权,但是因为没有锁不能进入),直到执行该同步块中的线程退出。

成员函数同步

在成员函数的返回值之前使用synchronized关键字,这告诉Java该方法是同步的。使用这种方式,锁为调用方法的那个对象this

  1. class Bank {
  2. private volatile int sum;
  3. public synchronized void add(int num) { // 同步
  4. sum += num;
  5. System.out.println(Thread.currentThread().getName() + " sum=" + sum);
  6. }
  7. }
  8. class Customer implements Runnable {
  9. private Bank bank;
  10. public Customer(Bank bank) { this.bank = bank; }
  11. @Override
  12. public void run() {
  13. for (int i = 0; i < 3; i++)
  14. bank.add(100);
  15. }
  16. }
  17. public class BankDemo {
  18. public static void main(String[] args) {
  19. Bank bank = new Bank();
  20. Customer a = new Customer(bank);
  21. Customer b = new Customer(bank);
  22. new Thread(a, "a").start();
  23. new Thread(b, "b").start();
  24. }
  25. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  26. b sum=100
  27. a sum=200
  28. a sum=300
  29. a sum=400
  30. b sum=500
  31. b sum=600
  32. *///:~

以上代码,比之前的银行存钱的代码相比,就是在add()方法声明中加上了synchronized关键字,输出顺序不一定,但一定是从100到600的逐步递增,这才是我们所期望的。每次只允许一个线程调用add()方法,另外一个线程必须要等到持有锁的那个线程退出add()方法时,才能继续执行add()方法(被阻塞)。

同步代码块

有时你不需要同步整个方法,而是同步方法中的一部分。Java可以对方法的一部分进行同步,Java同步块构造器用括号将对象(锁)括起来。

  1. class Bank {
  2. private volatile int sum;
  3. private Object obj = new Object(); // 自定义锁对象
  4. public void add(int num) {
  5. synchronized (obj) { // 锁或监视器对象
  6. sum += num;
  7. System.out.println(Thread.currentThread().getName() + " sum=" + sum);
  8. }
  9. }
  10. }
  11. class Customer implements Runnable {
  12. private Bank bank;
  13. public Customer(Bank bank) { this.bank = bank; }
  14. @Override
  15. public void run() {
  16. for (int i = 0; i < 3; i++)
  17. bank.add(100);
  18. }
  19. }
  20. public class BankDemo {
  21. public static void main(String[] args) {
  22. Bank bank = new Bank();
  23. Customer a = new Customer(bank);
  24. Customer b = new Customer(bank);
  25. new Thread(a, "a").start();
  26. new Thread(b, "b").start();
  27. }
  28. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  29. a sum=100
  30. a sum=200
  31. a sum=300
  32. b sum=400
  33. b sum=500
  34. b sum=600
  35. *///:~

静态函数同步

静态函数的同步声明和成员函数的同步声明一样,值得注意的是,静态函数同步块的锁为该函数所属字节码文件对象Class

多线程处理同一任务

如果需要卖电影票,票总共有120张,现在有很多渠道可以买票,我们可以上网买,也可以通过窗口买票,还可以电话订票。于是我们需要考虑这么一个问题,如何防止一张票被重复卖掉。

  1. class Ticket implements Runnable {
  2. private int num = 120; // 120张票,共享资源
  3. @Override
  4. public void run() {
  5. sell();
  6. }
  7. public synchronized void sell() {
  8. while (num > 0) {
  9. System.out.println(Thread.currentThread().getName() + ":" + num);
  10. num--;
  11. }
  12. }
  13. }
  14. public class TicketDemo {
  15. public static void main(String[] args) {
  16. Ticket ticket = new Ticket();
  17. new Thread(ticket, "电话售票").start();
  18. new Thread(ticket, "网络售票").start();
  19. new Thread(ticket, "窗口售票").start();
  20. }
  21. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  22. 电话售票:120
  23. 电话售票:119
  24. ...
  25. 电话售票:1
  26. *///:~

虽然没有出现卖出同一张票的情况,但是我们可以发现,每次运行只有一个窗口在卖票,其他窗口不能卖票,这明显是不符合我们所期望的。现在我们来分析一下这种情况是怎么出现的,当电话卖票线程获取了锁后,便一直会处于while循环里,直到运行完成后,才会释放锁,此时已经没有票了。所以我们应该对任意一个线程,只要卖掉一张票就应该释放锁。

  1. class Ticket implements Runnable {
  2. private int num = 120; // 共享资源
  3. @Override
  4. public void run() {
  5. while (true) {
  6. synchronized (this) {
  7. if (num > 0)
  8. sell();
  9. else
  10. break;
  11. }
  12. }
  13. }
  14. public synchronized void sell() {
  15. System.out.println(Thread.currentThread().getName() + ":" + num);
  16. num--;
  17. }
  18. }
  19. public class TicketDemo {
  20. public static void main(String[] args) {
  21. Ticket ticket = new Ticket();
  22. new Thread(ticket, "电话售票").start();
  23. new Thread(ticket, "网络售票").start();
  24. new Thread(ticket, "窗口售票").start();
  25. }
  26. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  27. 电话售票:120
  28. 窗口售票:119
  29. ...
  30. 网络售票:1
  31. *///:~

线程之间的协作

之前讲解了使用同步块来使得一个线程不会干涉另一个线程正在操作的共享资源(多线程处理同一任务),接下来我们来学习如何使得任务彼此之间可以协作,以使得多个任务可以一起工作去解决某个问题(多线程的任务不同)。现在的问题不是彼此之间的干涉,而是彼此之间的协调,因为在一些问题中,某些部分必须在其他部分被解决之前解决。

举个例子,烤鸭店和消费者,烤鸭店还没有烤好鸭子,消费者是不能进行消费的。因此在消费者消费之前,至少要有一只烤鸭准备好了。

通过共享对象通信

线程间发送信号的一个简单方式是在共享对象的变量里设置信号值。多个线程必须获得指向一个共享实例的引用,以便进行通信。如果它们持有的引用指向不同的实例,那么彼此将不能检测到对方的信号。

  1. // 共享资源代表烤鸭
  2. class Resource{
  3. boolean flag; // 通过标志位判断烤鸭是否做好了
  4. }

现在我们考虑这么一种情况:烤鸭店当发现没有鸭子了便会去生产一只鸭子,如果有鸭子就不生产,消费者每次消费一只鸭子。我们可以这么来实现,当没有鸭子时,将标志位flag设为false,烤鸭店便去生产一只鸭子,之后将flag设为true。消费者发现有鸭子了,就去消费一只flagfalse,以下是代码实现:

  1. import java.io.BufferedOutputStream;
  2. import java.io.FileNotFoundException;
  3. import java.io.FileOutputStream;
  4. import java.io.PrintStream;
  5. // 资源代表烤鸭
  6. class Resource{
  7. boolean flag; // 通过标志位判断烤鸭是否做好了
  8. }
  9. // 生产者代表餐厅
  10. class Producer implements Runnable{
  11. private Resource res;
  12. public Producer(Resource res) {
  13. this.res = res;
  14. }
  15. @Override
  16. public void run() {
  17. while (true)
  18. synchronized (res) { // 给临界区加上同步块
  19. if (!res.flag) {
  20. System.out.println("Producer run.."); // 生产烤鸭
  21. res.flag = true;
  22. } else {
  23. System.out.println("Resource exist!"); // 烤鸭未消费,不生产
  24. }
  25. }
  26. }
  27. }
  28. // 消费者
  29. class Consumer implements Runnable{
  30. private Resource res;
  31. public Consumer(Resource res) {
  32. this.res = res;
  33. }
  34. @Override
  35. public void run() {
  36. while (true)
  37. synchronized (res) { // 临界区
  38. if (res.flag) {
  39. System.out.println("Consumer eat.."); // 消费烤鸭
  40. res.flag = false;
  41. } else {
  42. System.out.println("Waitting Resource!"); // 没有烤鸭,等待中
  43. }
  44. }
  45. }
  46. }
  47. public class OneToOne {
  48. public static void main(String[] args) throws InterruptedException {
  49. PrintStream ps = logger(); // 开启日志
  50. Resource res = new Resource(); // 共享资源,也是两个线程的锁(监视器)
  51. Producer pro = new Producer(res); // 生产者
  52. Consumer con = new Consumer(res); // 消费者
  53. Thread t1 = new Thread(pro,"生产者");
  54. Thread t2 = new Thread(con,"消费者");
  55. t1.start();
  56. t2.start();
  57. Thread.sleep(10); // 记录10ms内的输出结果
  58. ps.close();
  59. }
  60. // 由于输出很多,为了看到全部的输出,将输出打印到文件中,在当前项目的log.txt可以查看输出结果
  61. public static PrintStream logger(){
  62. PrintStream ps = null;
  63. try {
  64. ps = new PrintStream(new BufferedOutputStream(new FileOutputStream("log.txt")),true);
  65. } catch (FileNotFoundException e) {
  66. e.printStackTrace();
  67. }
  68. System.setOut(ps);
  69. return ps;
  70. }
  71. } /* Output: // 输出顺序并不确定,以下log.txt文件中的一部分
  72. ...
  73. Resource exist!
  74. Consumer eat..
  75. Waitting Resource!
  76. Waitting Resource!
  77. Producer run..
  78. Resource exist!
  79. Resource exist!
  80. Resource exist!
  81. Resource exist!
  82. Resource exist!
  83. Resource exist!
  84. Resource exist!
  85. Resource exist!
  86. Consumer eat..
  87. Waitting Resource!
  88. Waitting Resource!
  89. Producer run..
  90. Resource exist!
  91. Resource exist!
  92. ...
  93. *///:~

通过上面是输出结果可以发现,虽然输出结果正确,但是不太符合我们所期望,我们期望的输出语句应该是"Producer run.."和"Consumer eat.."这两中语句,每生产一只烤鸭就应该通知消费者,可以去消费了,而不是自己一直去看看鸭子还在不在,鸭子在不在应该由消费者告诉烤鸭店:“老板,烤鸭没啦!”在下一篇,笔者会讲解如何解决这个问题。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注