[关闭]
@lemonguge 2017-09-13T14:48:45.000000Z 字数 6199 阅读 363

多线程的新特性(一)

Concurrency


有关Java SE5线程新特征的内容全部在java.util.concurrentjava.util.concurrent.atomicjava.util.concurrent.locks三个包下面,里面包含数目众多的接口和类,熟悉这部分API特征是一项艰难的学习过程。

在这一篇文章,我看了《Java并发编程实践》、《Java Programming》以及很多的博客,所以难免文中会有一些参考。在写这篇文章的过程中,可能会有一些认知不正确的地方,但这将是一起进步的过程。新特征对写出多线程程序没有必须的关系,在Java SE5之前就可以写出很优秀的多线程程序,只是代价不一样而已。为了编写高效稳定可靠的多线程程序,线程部分的新增内容显得尤为重要。

在讲解之前,首先回顾之前文章提到的两个概念:

  1. 同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区(确保了不可分隔操作的原子性)。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去。
  2. volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被立即写回到主存中去。

互斥与原子性

原子操作是不能被CPU运行切换所中断的,一旦操作开始,那么它一定可以执行完毕。其中部分的Java语句可以认定为具有原子性的:

  1. 除了longdouble之外的基本类型变量的读取和写入操作。
  2. 所有声明为volatile的变量的读和写,包括longdouble类型以及引用类型。

通常对域中的值做赋值和返回(getset 方法)操作都是原子性的,int i = 1;这个语句就是原子性的;i++;不具有原子性,结合之前讲解的Java内存模型中将很好理解,假如有两个线程同时运行在一个CPU上,那么这两个线程将以时间片的形式来体现出多任务。它们将共用同一个CPU缓存,一个线程A执行的是i++;语句,另一个线程B执行i=3;语句。可以想象出一种情况,当线程A执行完读i=1时,这时候CPU进行了运行切换去执行线程B,那么i将会变成3,再执行i+1,那么i的结果将会等于4!这是一个“读-改-写”的过程。

互斥和原子起到的效果是一样的,也不能说表达的是同一个意思。我曾经看到一句话,非常好的描述了这两者的区别。

互斥是这个资源只有我用,你不许用;原子是我一口气把活干完,不间断。


复合操作

在有关Java线程的讨论中,一个常不正确的知识是“原子操作是不需要进行同步控制”。当把多个原子操作整合成一个复合操作时,还是需要同步的。

  1. class CompositeOper implements Runnable {
  2. // 保证从主存中读取,而不是CPU缓存
  3. private volatile AtomOper atom;
  4. public CompositeOper(AtomOper atom) { this.atom = atom; }
  5. @Override
  6. public void run() {
  7. atom.num = 10; // 原子操作
  8. try {
  9. Thread.sleep(1000);
  10. } catch (InterruptedException e) {
  11. }
  12. System.out.println(Thread.currentThread().getName() + ":" + atom.num); // 原子操作
  13. }
  14. }
  15. public class AtomOper {
  16. public int num;
  17. public static void main(String[] args) throws InterruptedException {
  18. AtomOper atom = new AtomOper();
  19. new Thread(new CompositeOper(atom)).start();
  20. Thread.sleep(500);
  21. atom.num = 12;
  22. }
  23. } /* Output:
  24. Thread-0:12
  25. *///:~

可以发现以上示例并没有输出10,而是输出12。为了防止主线程中途修改,保证使得线程Thread-0输出10,只需要给主线程和线程Thread-0都加上同样的隐式锁atom即可。代码如下所示:

  1. class CompositeOper implements Runnable {
  2. private volatile AtomOper atom;
  3. public CompositeOper(AtomOper atom) { this.atom = atom; }
  4. @Override
  5. public void run() {
  6. synchronized (atom) { // 加上同步代码块
  7. atom.num = 10;
  8. try {
  9. Thread.sleep(1000);
  10. } catch (InterruptedException e) {
  11. }
  12. System.out.println(Thread.currentThread().getName() + ":" + atom.num);
  13. }
  14. }
  15. }
  16. public class AtomOper {
  17. public int num;
  18. public static void main(String[] args) throws InterruptedException {
  19. AtomOper atom = new AtomOper();
  20. new Thread(new CompositeOper(atom)).start();
  21. Thread.sleep(500);
  22. synchronized (atom) { // 和线程1同一把锁
  23. atom.num = 12;
  24. }
  25. }
  26. } /* Output:
  27. Thread-0:10
  28. *///:~

正如上面代码所演示的,使复合操作在完整的运行期间占有锁,以确保其行为是原子的。


原子变量

由于volatile变量与锁相比是更轻量的同步机制,它们不会引起上下文的切换和线程调度。尽管提供了相似的可见性保证,但是它们不能用于构建原子化的复合操作。

首先看一个之前提到过的银行存钱示例:

  1. // 银行
  2. class Bank {
  3. private int sum; // 银行库存
  4. public void add(int num) { // 每次存钱,库存增加
  5. sum += num;
  6. }
  7. public int getSum() { return sum; }
  8. }
  9. // 储户
  10. class Customer implements Runnable {
  11. private Bank bank;
  12. public Customer(Bank bank) { this.bank = bank; }
  13. @Override
  14. public void run() {
  15. for (int i = 0; i < 5000; i++)
  16. // 存5000次,每次存100
  17. bank.add(100);
  18. }
  19. }
  20. public class BankDemo {
  21. public static void main(String[] args) throws InterruptedException {
  22. Bank bank = new Bank();
  23. // 指定a和b储户存钱的银行为bank
  24. Customer a = new Customer(bank);
  25. Customer b = new Customer(bank);
  26. // 开启两个线程可以使a和b同时存钱
  27. new Thread(a, "a").start(); // 指定线程名为a
  28. new Thread(b, "b").start(); // 指定线程名为b
  29. Thread.sleep(5000);
  30. System.out.println(bank.getSum());
  31. }
  32. } /* Output: 输出结果并不确定
  33. 762900
  34. *///:~

可以发现上述示例中没有输出我们所期望的数值1000000,以前为了解决这个问题可以给add()方法加上synchronized关键字,但是如果我们使用可见性的原子变量同样可以解决这个问题,代码如下:

  1. import java.util.concurrent.atomic.AtomicInteger;
  2. // 银行
  3. class Bank {
  4. private volatile AtomicInteger sum = new AtomicInteger(); // 银行库存
  5. public void add(int num) { // 每次存钱,库存增加
  6. int temp = sum.addAndGet(num);
  7. }
  8. public int getSum() {
  9. return sum.get();
  10. }
  11. }
  12. // 储户
  13. class Customer implements Runnable {
  14. private Bank bank;
  15. public Customer(Bank bank) {
  16. this.bank = bank;
  17. }
  18. @Override
  19. public void run() {
  20. for (int i = 0; i < 3; i++) // 存三次,每次存100
  21. bank.add(100);
  22. }
  23. }
  24. public class BankDemo {
  25. public static void main(String[] args) throws InterruptedException {
  26. Bank bank = new Bank();
  27. // 指定a和b储户存钱的银行为bank
  28. Customer a = new Customer(bank);
  29. Customer b = new Customer(bank);
  30. // 开启两个线程可以使a和b同时存钱
  31. new Thread(a, "a").start(); // 指定线程名为a
  32. new Thread(b, "b").start(); // 指定线程名为b
  33. Thread.sleep(1000);
  34. System.out.println(bank.getSum());
  35. }
  36. }

显式的锁和监视器

在同步块中,锁也是监视器对象,在Java SE5的java.util.concurrent.locks包中定义了显式的锁和监视器。Lock对象必须被显式的创建、锁定和释放,因此它与同步块中的隐式锁相比,会缺乏优雅性。但对于解决某些问题来说,它更加灵活。

在临界区中使用适当Java同步块就可以避免竞态条件,对于同任务的多线程,只要一个线程获得了锁,所有其他等待进入该同步块的线程将被阻塞。这就是隐式锁体现出来的互斥性,在java.util.concurrent.locks包有一个ReentrantLock类,这是一个互斥锁。这个类实现了Lock接口,我们应该先去了解一下Lock接口具有的功能。

Condition类是显式的监听器,之前介绍过了通过监听器可以改变线程的状态。通过查看API可以发现:

通过这几个方法,我们可以对之前的whilenotifyAll进行改进,因为可以主动控制监听器来唤醒对方线程。

  1. import java.util.concurrent.locks.Condition;
  2. import java.util.concurrent.locks.ReentrantLock;
  3. import java.util.concurrent.locks.Lock;
  4. //资源代表烤鸭
  5. class Resource{
  6. boolean flag; // 通过标志位判断烤鸭是否做好了
  7. Lock lock = new ReentrantLock(); // 创建互斥锁
  8. // 有两个任务的多线程,所以需要两个监视器,每个监视器监视运行一个任务的多线程
  9. Condition prodCond = lock.newCondition();
  10. Condition ConsCond = lock.newCondition();
  11. }
  12. //生产者代表餐厅
  13. class Producer implements Runnable{
  14. private Resource res;
  15. public Producer(Resource res) {
  16. this.res = res;
  17. }
  18. @Override
  19. public void run() {
  20. while (true) {
  21. res.lock.lock(); // 临界区
  22. while (res.flag)
  23. try { res.prodCond.await(); } catch (InterruptedException e) { }
  24. System.out.println(Thread.currentThread().getName() + "@Producer run..");
  25. res.flag = true;
  26. res.ConsCond.signal(); // 唤醒消费者,烤鸭做好啦
  27. res.lock.unlock();
  28. }
  29. }
  30. }
  31. //消费者
  32. class Consumer implements Runnable{
  33. private Resource res;
  34. public Consumer(Resource res) {
  35. this.res = res;
  36. }
  37. @Override
  38. public void run() {
  39. while (true){
  40. res.lock.lock(); // 临界区
  41. while (!res.flag)
  42. try { res.ConsCond.await(); } catch (Exception e) { }
  43. System.out.println(Thread.currentThread().getName() + "@Consumer eat..");
  44. res.flag = false;
  45. res.prodCond.signal(); // 唤醒烤鸭店,烤鸭吃完啦
  46. res.lock.unlock();
  47. }
  48. }
  49. }
  50. public class Cond {
  51. public static void main(String[] args) {
  52. Resource res = new Resource();
  53. Producer pro1 = new Producer(res);
  54. Producer pro2 = new Producer(res);
  55. Consumer con1 = new Consumer(res);
  56. Thread t1 = new Thread(pro1,"生产者1");
  57. Thread t2 = new Thread(pro2,"生产者2");
  58. Thread t3 = new Thread(con1,"消费者");
  59. t1.start();
  60. t2.start();
  61. t3.start();
  62. }
  63. } ///:OK~

笔者往往会为一个任务创建一个监听器,监视器调用await()方法使运行该任务的当前线程处于冻结状态,一个线程任务可以被多个线程共享,这些处于冻结状态的线程们也只能被冻结它们的那个监视器所唤醒。


线程池

线程池就是Java SE5的新特征之一,在之前的文章讲到过线程的代价,创建一个线程是需要占用系统资源的,尤其是反复创建线程对象所带来的性能开销更大。为了节省这种开销,就需要用到线程池技术。

线程池的基本思想是一种对象池的思想,开辟一块内存空间,里面存放了众多(未死亡)的线程,池中线程执行调度由池管理器来处理。当有线程任务时,从池中取一个,执行完成后线程对象归池。在Java SE5之前,要实现一个线程池是相当有难度的,现在Java SE5为我们做好了一切,我们只需要按照提供的API来使用,即可享受线程池带来的极大便利。

java.util.concurrent.Executors类提供大量创建连接池的静态方法。

// TODO

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