[关闭]
@lemonguge 2015-07-07T01:49:20.000000Z 字数 7416 阅读 383

并发性和多线程(一)

Concurrency


Java是最先支持多线程的开发的语言之一

在过去单CPU时代,单任务在一个时间点只能执行单一程序。之后发展到多任务阶段,计算机能在同一时间点并行执行多任务或多进程。虽然并不是真正意义上的“同一时间点”,而是多个任务或进程共享一个CPU,并交由操作系统来完成多任务间对CPU的运行切换,以使得每个任务都有机会获得一定的时间片运行。

现代的计算机伴随着多核CPU的出现,多线程技术使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个CPU在执行该程序。当一个程序运行在多线程下,就好像有多个CPU在同时执行该程序。


进程与线程

进程就是正在进行中的程序,线程就是进程中一个负责程序执行的控制单元(执行路径)。

  1. // 进程
  2. public class Proc {
  3. public static void main(String[] args) throws Exception {
  4. Runtime rt = Runtime.getRuntime();
  5. Process p = rt.exec("notepad.exe"); // 打开记事本
  6. Thread.sleep(1000); // 等待1秒
  7. p.destroy(); // 关闭记事本
  8. }
  9. }

开启多个线程是为了同时运行多部分代码。每一个线程都有自己运行的内容,这个内容可以称为线程要执行的任务

JVM启动时就启动了多个线程,至少有两个线程可以分析的出来:
1. 执行main函数的线程,该线程的任务代码都定义在main函数中。
2. 负责垃圾回收的线程。

  1. class Demo {
  2. @Override
  3. protected void finalize() throws Throwable {
  4. System.out.println(Thread.currentThread().getName()+"@thread run.."); // 静态方法获取当前线程名称
  5. super.finalize();
  6. }
  7. }
  8. public class Thd {
  9. public static void main(String[] args) throws InterruptedException {
  10. System.out.println(Thread.currentThread().getName()+"@thread run..");
  11. new Demo();
  12. System.gc();
  13. Thread.sleep(100);
  14. }
  15. } /* Output:
  16. main@thread run..
  17. Finalizer@thread run..
  18. *///:~

多线程的优点和代价

想想这么一个问题,如果一个线程在读一个内存时,另一个线程正向该内存进行写操作,那进行读操作的那个线程将获得什么结果呢?是写操作之前旧的值?还是写操作成功之后的新值?或是一半新一半旧的值?或者,如果是两个线程同时写同一个内存,在操作完成后将会是什么结果呢?是第一个线程写入的值?还是第二个线程写入的值?还是两个线程写入的一个混合值?

因此如没有合适的预防措施,任何结果都是可能的。而且这种行为的发生甚至不能预测,所以结果也是不确定性的。我们在开发中会常常遇见上面的问题,尽管面临着很多的挑战,多线程有一些优点使得它一直被使用。

多线程的优点

多线程的代价

从一个单线程的应用到一个多线程的应用并不仅仅带来好处,它也会有一些代价。不要仅仅为了使用多线程而使用多线程。而应该明确在使用多线程时能多来的好处比所付出的代价大的时候,才使用多线程。


创建并运行Java线程

任务

创建一个自定义线程时,我们首先都需要明确此线程运行的任务

编写线程运行时执行的代码(任务)有两种方式:一种是创建java.lang.Thread子类的一个实例并重写run方法,第二种是创建类的时候实现Runnable接口。

对于创建线程任务的这两种方式哪种好,并没有一个确定的答案,它们都能满足要求。笔者更倾向于实现Runnable接口这种方法。根据面向对象的思想,我们应该明确任务是否属于Thread继承体系(is a),还仅只是额外的功能,而往往在开发中需要的是一个多线程的功能。此外线程池可以有效的管理实现了Runnable接口的线程,如果线程池满了,新的线程就会排队等候执行,直到线程池空闲出来为止。而如果线程是通过实现Thread子类实现的,这将会复杂一些。

运行

调用线程对象的start方法开启线程。

run方法会在调用start方法之后被执行,一旦线程启动后start方法就会立即返回,而不会等待到run方法执行完毕才返回,就好像run方法是在另外一个CPU上执行一样。

  1. // Java线程的演示
  2. class RunExt extends Thread {
  3. @Override
  4. public void run() {
  5. System.out.println(Thread.currentThread().getName() + "@run..");
  6. }
  7. }
  8. class RunImp implements Runnable {
  9. @Override
  10. public void run() {
  11. System.out.println(Thread.currentThread().getName() + "@run..");
  12. }
  13. }
  14. public class Run {
  15. public static void main(String[] args) {
  16. System.out.println(Thread.currentThread().getName() + "@run..");
  17. new RunExt().start(); // 继承自Thread的线程
  18. new Thread(new RunImp()).start(); // 实现Runnable接口,作为构造函数的参数进行传递
  19. }
  20. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  21. main@run..
  22. Thread-1@run..
  23. Thread-0@run..
  24. *///:~

需要注意的是,尽管启动线程的顺序是有序的,但是执行的顺序并非是有序的。JVM和操作系统一起决定了线程的执行顺序,执行顺序和线程的启动顺序并非一定是一致的。

常见错误

创建并运行一个线程所犯的常见错误是调用线程的run方法而非start方法,起初你并不会感觉到有什么不妥,因为run方法的确如你所愿的被调用了。但是事实上run方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。想要让创建的新线程执行run方法,必须调用新线程的start方法。

  1. public class Run {
  2. public static void main(String[] args) {
  3. run();
  4. }
  5. public static void run() {
  6. Thread thread = new Thread("新的线程") { // 自定义线程名
  7. @Override
  8. public void run() {
  9. System.out.println(Thread.currentThread().getName());
  10. }
  11. };
  12. thread.run(); // 并没有开启新的线程
  13. }
  14. } /* Output:
  15. main // 当前线程仍然是main线程,而没有输出:新的线程
  16. *///:~

多线程的状态

在讲解多线程的状态之前,笔者有必要让大家了解一下两个概念:

对于上面的这两个概念,举个例子:你在食堂排队(处理队列)打饭,还没有轮到你(此时你仅具有执行资格),过了一段时间后,终于轮到食堂阿姨(CPU)给你打饭(你还在排队中,同时具有执行资格和执行权),当你打完饭你就离开了(释放执行权和执行资格),吃完要是你还是觉得饿,你又会去排队打饭。

在大家明白这两个概念以后,笔者认为多线程的状态有五种:

  1. 被创建状态:还未调用start方法开启线程。
  2. 运行状态:同时具备执行资格和执行权。
  3. 冻结状态:释放执行权的同时释放执行资格。
  4. 临时阻塞状态:具备着执行资格,但是不具备执行权,正在等待执行权。
  5. 消亡状态:run方法(线程任务)的结束或是调用了stop方法(该方法已过时)。
  1. public class Run {
  2. public static void main(String[] args) throws InterruptedException {
  3. stop();
  4. }
  5. public static void stop() throws InterruptedException {
  6. Thread thread = new Thread("新的线程") {
  7. @Override
  8. public void run() {
  9. for (int i = 0; i < 100; i++)
  10. System.out.println(Thread.currentThread().getName() + ":" + i);
  11. }
  12. };
  13. thread.start(); // 开启线程
  14. thread.sleep(5);
  15. thread.stop(); // 终止线程
  16. }
  17. } ///~ // 新的线程并没有输出到100就被终止,处于消亡状态

对于运行状态、临时阻塞状态和冻结状态,有一个相互的转换关系。

notify()唤醒该监视器所监视的处于冻结状态的所有线程的任意一个线程,notifyAll()唤醒该监视器所监视的所有冻结线程。(监视器后面会进行介绍)


竞态条件与临界区

当多个线程同时访问同一个资源,并且其中的一个或者多个线程对这个资源进行了写操作,才会产生竞态条件(race conditions)。导致竞态条件发生的代码区称作临界区

举个例子:有储户a和b向同一个银行存钱,都存三次且每次都存100。

  1. // 银行
  2. class Bank {
  3. private int sum; // 银行库存
  4. public void add(int num) { // 每次存钱,库存增加
  5. sum += num;
  6. System.out.println(Thread.currentThread().getName() + " sum=" + sum);
  7. }
  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 < 3; i++) // 存三次,每次存100
  16. bank.add(100);
  17. }
  18. }
  19. public class BankDemo {
  20. public static void main(String[] args) {
  21. Bank bank = new Bank();
  22. // 指定a和b储户存钱的银行为bank
  23. Customer a = new Customer(bank);
  24. Customer b = new Customer(bank);
  25. // 开启两个线程可以使a和b同时存钱
  26. new Thread(a, "a").start(); // 指定线程名为a
  27. new Thread(b, "b").start(); // 指定线程名为b
  28. }
  29. } /* Output: // 输出顺序并不确定,以下是一种输出顺序
  30. b sum=200
  31. a sum=200
  32. a sum=400
  33. a sum=500
  34. b sum=300 // 运行程序的电脑为双核(以后会进行解释)
  35. b sum=600
  36. *///:~

通过以上的输出结果可以发现,程序明显有问题!我们可以预料的正确输出应该是从100到600逐步递增。为什么会出现这种情况呢?a和b两个线程同时对银行库存sum进行了写操作,我们无法知道操作系统何时会在两个线程之间切换,具体分析该输出结果的前两条如下:

如果a和b储户不是向同一个银行存钱就不会导致竞态条件。

  1. Bank bank1 = new Bank();
  2. Bank bank2 = new Bank();
  3. Customer a = new Customer(bank1);
  4. Customer b = new Customer(bank2);

线程控制逃逸规则:如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。

资源可以是对象,数组,文件,数据库连接,套接字等等。Java中你无需主动销毁对象,所以“销毁”指不再有引用指向对象。


线程安全及不可变性

多个线程同时同一个资源不会产生竞态条件,线程安全的代码不包含竞态条件。我们可以通过创建不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全。

  1. public class ImmutableValue { // 线程安全
  2. private int value = 0;
  3. public ImmutableValue(int value) {
  4. this.value = value;
  5. }
  6. public int getValue() { return this.value; }
  7. }

上面这段代码意味着一旦ImmutableValue实例被创建,value(通过构造函数赋值的)成员变量就不能再被修改,这就是不可变性。但你可以通过getValue()方法读取这个变量的值。

  1. public class ImmutableValue { // 也是线程安全
  2. private int value = 0;
  3. public ImmutableValue(int value) {
  4. this.value = value;
  5. }
  6. public int getValue() { return this.value; }
  7. public ImmutableValue add(int valueToAdd) {
  8. return new ImmutableValue(this.value + valueToAdd);
  9. }
  10. }

请注意add()方法以加法操作的结果作为一个新的ImmutableValue类实例返回,而不是直接对它自己的value变量进行操作,所以也是线程安全的。

即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的。

  1. public class Calculator {
  2. private ImmutableValue currentValue = null;
  3. public ImmutableValue getValue() {
  4. return currentValue;
  5. }
  6. public void setValue(ImmutableValue newValue) {
  7. this.currentValue = newValue;
  8. }
  9. public void add(int newValue) {
  10. this.currentValue = this.currentValue.add(newValue);
  11. }
  12. }

Calculator类持有一个成员变量currentValue指向ImmutableValue实例。注意,通过setValue()方法和add()方法可能会改变这个引用。Calculator类本身还是可变的,因此Calculator类不是线程安全的。

要使Calculator类实现线程安全,将getValue()setValue()add()方法都声明为同步方法即可。

不变(Immutable)和只读(Read Only)是不同的,当一个变量是“只读”时,变量的值不能直接改变,但是可以在其它变量发生改变的时候发生改变。比如,一个人的出生年月日是“不变”属性,而一个人的年龄便是“只读”属性,但是不是“不变”属性。随着时间的变化,一个人的年龄会随之发生变化,而一个人的出生年月日则不会变化。这就是“不变”和“只读”的区别。

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