Java核心技术点之多线程(上)

桂林seo / 随笔杂谈 / 时间:2017-10-27 19:08

为什么使用多线程

并发与并行

我们知道,在单核机器上,“多进程”并不是真正的多个进程在同时执行,而是通过CPU时间分片,操作系统快速在进程间切换而模拟出来的多进程。我们通常把这种情况成为并发,也就是多个进程的运行行为是“一并发生”的,但不是同时执行的,因为CPU核数的限制(PC和通用寄存器只有一套,严格来说在同一时刻只能存在一个进程的上下文)。

现在,我们使用的计算机基本上都搭载了多核CPU,这时,我们能真正的实现多个进程并行执行,这种情况叫做并行,因为多个进程是真正“一并执行”的(具体多少个进程可以并行执行取决于CPU核数)。综合以上,我们知道,并发是一个比并行更加宽泛的概念。也就是说,在单核情况下,并发只是并发;而在多核的情况下,并发就变为了并行。下文中我们将统一用并发来指代这一概念。

阻塞与非阻塞

UNIX系统内核提供了一个名为read的函数,用来读取文件的内容:

typedef ssize_t int;typedef size_t unsigned;ssize_t read(int fd,

void *buf, size_t n);

这个函数从描述符为fd的当前文件位置复制至多n个字节到内存缓冲区buf。若执行成功则返回读取到的字节数;若失败则返回-1。read系统调用默认会阻塞,也就是说系统会一直等待这个函数执行完毕直到它产生一个返回值。然而我们知道,磁盘通常是一种慢速I/O设备,这意味着我们用read函数读取磁盘文件内容时,往往需要比较长的时间(相对于访问内存来说)。那么阻塞的时候我们当然不想让系统傻等着,我们想在这期间做点儿别的事情,等着磁盘准备好了通知我们一下,我们再来读取文件内容。实际上,操作系统正是这样做的。当阻塞在read这类系统调用中的时候,操作系统通常都会让该进程暂时休眠,调度一个别的进程来执行,以免干等着浪费时间,等到磁盘准备好了可以让我们来进行I/O了,它会发送一个中断信号通知操作系统,这时候操作系统重新调度原来的进程来继续执行read函数。这就是通过多进程实现的并发。

多进程 vs 多线程

进程就是一个执行中的程序实例,而线程可以看作一个进程的最小执行单元。线程与进程间的一个显著区别在于每个进程都有一整套变量,而同一个进程间的多个线程共享该进程的数据。也就是说在通常情况下,多线程在数据共享上要比多进程更加便捷。

然而,有时候,多线程共享数据的便捷容易可能会成为一个让我们头疼的问题,我们在后文中会具体提到常见的问题及相应的解决方案。在上面的read函数的例子中,如果我们使用多线程,可以使用一个主线程去进行I/O的工作,再用一个或几个工作线程去执行一些轻量计算任务,这样当主线程阻塞时,线程调度程序会调度我们的工作线程来执行计算任务,从而更加充分的利用CPU时间片。而且,在多核机器上,我们的多个线程可以并行执行在多个核上,进一步提升效率。

(Java高级程序员)学习交流QQ群:478052716 你在学习Java的过程中或者在工作中遇到什么问题都可以来群里提问,阿里Java高级大牛直播讲解知识点,分享知识,多年工作经验的梳理和总结,带着大家全面、科学地建立自己的技术体系和技术认知!非喜勿进!

如何使用多线程

线程执行模型

每个进程刚被创建时都只含有一个线程,这个线程通常被称作主线程(main thread)。而后随着进程的执行,若遇到创建新线程的代码,就会创建出新线程,而后随着新线程被启动,多个线程就会并发地运行。某时刻,主线程阻塞在一个慢速系统调用中(比如前面提到的read函数),这时线程调度程序会让主线程暂时休眠, 调度另一个线程来作为当前运行的线程。

创建一个新线程

通过实现Runnable接口

在Java中,有两种方法可以创建一个新线程。第一种方法是定义一个实现Runnable接口的类并实例化,然后将这个对象传入Thread的构造器来创建一个新线程,如以下代码所示:

class MyRunnable implements Runnable {

public void run() {

//这里是新线程需要执行的任务

}

}

Runnable r = new MyRunnable();

Thread t = new Thread(r);

通过继承Thread类

第二种创建一个新线程的方法是直接定义一个Thread的子类并实例化,从而创建一个新线程。比如以下代码:

class MyThread extends Thread {

public void run() {

//这里是线程要执行的任务

}

}

创建了一个线程对象后,我们直接对其调用start方法即可启动这个线程:

t.start();

两种方式的比较

既然有两种方式可以创建线程,那么我们该使用哪一种呢?首先,直接继承Thread类的方法看起来更加方便,但它存在一个局限性:由于Java中不允许多继承,我们自定义的类继承了Thread后便不能再继承其他类,这在有些场景下会很不方便;实现Runnable接口的那个方法虽然稍微繁琐些,但是它的优点在于自定义的类可以继承其他的类。

线程的属性

线程的状态

线程在它的生命周期中可能处于以下几种状态之一:

New(新生):线程对象刚刚被创建出来;

Runnable(可运行):在线程对象上调用start方法后,相应线程便会进入Runnable状态,若被线程调度程序调度,这个线程便会成为当前运行(Running)的线程;

Blocked(被阻塞):若一段代码被线程A”上锁“,此时线程B尝试执行这段代码,线程B就会进入Blocked状态;

Waiting(等待):当线程等待另一个线程通知线程调度器一个条件时,它本身就会进入Waiting状态;

Time Waiting(计时等待):计时等待与等待的区别是,线程只等待一定的时间,若超时则不再等待;

Terminated(被终止):线程的run方法执行完毕或者由于一个未捕获的异常导致run方法意外终止会进入Terminated状态。

后文中若不加特殊说明的话,我们会用阻塞状态统一指代Blocked、Waiting、Time Waiting。

线程的优先级

在Java中,每个线程都有一个优先级,默认情况下,线程会继承它的父线程的优先级。可以用setPriority方法来改变线程的优先级。Java中定义了三个描述线程优先级的常量:MAX_PRIORITY、NORM_PRIORITY、MIN_PRIORITY。

每当线程调度器要调度一个新的线程时,它会首先选择优先级较高的线程。然而线程优先级是高度依赖于操作系统的,在有些系统的Java虚拟机中,甚至会忽略线程的优先级。因此我们不应该将程序逻辑的正确性依赖于优先级。线程优先级相关的API如下:

void setPriority(int newPriority) //设置线程的优先级,可以使用系统提供的三个优先级常量

static void yield() //使当前线程处于让步状态,这样当存在其他优先级大于等于本线程的线程时,线程调度程序会调用那个线程

Thread类

Thread实现了Runnable接口,关于这个类的以下实例域需要我们了解:

private volatile char name[]; //当前线程的名字,可在构造器中指定

private int priority; //当前线程优先级

private Runnable target; //当前要执行的任务

private long tid; //当前线程的ID

Thread类的常用方法除了我们之前提到的用于启动线程的start外还有:

sleep方法: 这是一个静态方法,作用是让当前线程进入休眠状态(但线程不会释放已获取的锁),这个休眠状态其实就是我们上面提到过的Time Waiting状态,从休眠状态“苏醒”后,线程会进入到Runnable状态。sleep方法有两个重载版本,声明分别如下:

//让当前线程休眠millis指定的毫秒数

public static native void sleep(long millis) throws InterruptedException;

//在毫秒数的基础上还指定了纳秒数,控制粒度更加精细

public static native void sleep(long millis, int nanos) throws InterruptedException;

join方法: 这是一个实例方法,在当前线程中对一个线程对象调用join方法会导致当前线程停止运行,等那个线程运行完毕后再接着运行当前线程。也就是说,把当前线程还没执行的部分“接到”另一个线程后面去,另一个线程运行完毕后,当前线程再接着运行。join方法有以下重载版本:

public final synchronized void join() throws InterruptedException;

public final synchronized void join(long millis) throws InterruptedException;

public final synchronized void join(long millis, int nanos) throws InterruptedException;

无参数的join表示当前线程一直等到另一个线程运行完毕,这种情况下当前线程会处于Wating状态;带参数的表示当前线程只等待指定的时间,这种情况下当前线程会处于Time Waiting状态。当前线程通过调用join方法进入Time Waiting或Waiting状态后,会释放已经获取的锁。实际上,join方法内部调用了Object类的实例方法wait,关于这个方法我们下面会具体介绍。

yield方法,这是一个静态方法,作用是让当前线程“让步”,目的是为了让优先级不低于当前线程的线程有机会运行,这个方法不会释放锁。

interrupt方法,这是一个实例方法。每个线程都有一个中断状态标识,这个方法的作用就是将相应线程的中断状态标记为true,这样相应的线程调用isInterrupted方法就会返回true。通过使用这个方法,能够终止那些通过调用可中断方法进入阻塞状态的线程。常见的可中断方法有sleep、wait、join,这些方法的内部实现会时不时的检查当前线程的中断状态,若为true会立刻抛出一个InterruptedException异常,从而终止当前线程。

以下这幅图很好的诠释了随着各种方法的调用,线程在不同的状态之间的切换

wait方法与notify/notifyAll方法

wait方法

wait方法是Object类中定义的实例方法。在指定对象上调用wait方法能够让当前线程进入阻塞状态(前提时当前线程持有该对象的内部锁(monitor)),此时当前线程会释放已经获取的那个对象的内部锁,这样一来其他线程就可以获取这个对象的内部锁了。当其他线程获取了这个对象的内部锁,进行了一些操作后可以调用notify方法来唤醒正在等待该对象的线程。

notify/notifyAll方法

notify/notifyAll方法也是Object类中定义的实例方法。它俩的作用是唤醒正在等待相应对象的线程,区别在于前者唤醒一个等待该对象的线程,而后者唤醒所有等待该对象的线程。这么说比较抽象,下面我们来举一个具体的例子来说明以下wait和notify/notifyAll的用法。请看以下代码(转自[Java并发编程:线程间协作的两种方式]

1 public class Test {

2 private int queueSize = 10;

3 private PriorityQueue queue = new PriorityQueue(queueSize);

4

5 public static void main(String[] args) {

6 Test test = new Test();

7 Producer producer = test.new Producer();

8 Consumer consumer = test.new Consumer();

9

10 producer.start();

11 consumer.start();

12 }

13

14 class Consumer extends Thread{

15

16 @Override

17 public void run() {

18 consume();

19 }

20

21 private void consume() {

22 while(true){

23 synchronized (queue) {

24 while(queue.size() == 0){

25 try {

26 System.out.println(“队列空,等待数据”);

27 queue.wait();

28 } catch (InterruptedException e) {

29 e.printStackTrace();

30 queue.notify();

31 }

32 }

33 queue.poll(); //每次移走队首元素

34 queue.notify();

35 System.out.println(“从队列取走一个元素,队列剩余”+queue.size()+”个元素”);

36 }

37 }

38 }

39 }

40

41 class Producer extends Thread{

42

43 @Override

44 public void run() {

45 produce();

46 }

47

48 private void produce() {

49 while(true){

50 synchronized (queue) {

51 while(queue.size() == queueSize){

52 try {

53 System.out.println(“队列满,等待有空余空间”);

54 queue.wait();

55 } catch (InterruptedException e) {

56 e.printStackTrace();

57 queue.notify();

58 }

59 }

60 queue.offer(1); //每次插入一个元素

61 queue.notify();

62 System.out.println(“向队列取中插入一个元素,队列剩余空间:”+(queueSize-queue.size()));

63 }

64 }

65 }

66 }

67 }

以上代码描述的是经典的“生产者-消费者”问题。Consumer类代表消费者,Producer类代表生产者。在生产者进行生产之前(对应第48行的produce方法),会获取queue的内部锁(monitor)。然后判断队列是否已满,若满了则无法再生产,所以在第54行调用queue.wait方法,从而等待在queue对象上。(释放了queue的内部锁)此时生产者能够能够获取queue的monitor从而进入第21行的consume方法,这样一来它就会通过第33行的queue.poll方法进行消费,于是队列不再满了,接着它在第34行调用queue.notify方法来通知正在等待的生产者,生产者就会从刚才阻塞的wait方法(第54行)中返回。

同理,当队列空时,消费者也会等待(第27行)生产者来唤醒(第61行)。

await方法和signal/signalAll方法是wait方法和notify/notifyAll方法的升级版,在后文中会具体介绍它们与wait、notify/notifyAll之间的关系。

wait方法与notify/notifyAll方法

wait方法

wait方法是Object类中定义的实例方法。在指定对象上调用wait方法能够让当前线程进入阻塞状态(前提时当前线程持有该对象的内部锁(monitor)),此时当前线程会释放已经获取的那个对象的内部锁,这样一来其他线程就可以获取这个对象的内部锁了。当其他线程获取了这个对象的内部锁,进行了一些操作后可以调用notify方法来唤醒正在等待该对象的线程。

notify/notifyAll方法

notify/notifyAll方法也是Object类中定义的实例方法。它俩的作用是唤醒正在等待相应对象的线程,区别在于前者唤醒一个等待该对象的线程,而后者唤醒所有等待该对象的线程。这么说比较抽象,下面我们来举一个具体的例子来说明以下wait和notify/notifyAll的用法。请看以下代码(转自[Java并发编程:线程间协作的两种方式]

1 public class Test {

2 private int queueSize = 10;

3 private PriorityQueue queue = new PriorityQueue(queueSize);

4

5 public static void main(String[] args) {

6 Test test = new Test();

7 Producer producer = test.new Producer();

8 Consumer consumer = test.new Consumer();

9

10 producer.start();

11 consumer.start();

12 }

13

14 class Consumer extends Thread{

15

16 @Override

17 public void run() {

18 consume();

19 }

20

21 private void consume() {

22 while(true){

23 synchronized (queue) {

24 while(queue.size() == 0){

25 try {

26 System.out.println(“队列空,等待数据”);

27 queue.wait();

28 } catch (InterruptedException e) {

29 e.printStackTrace();

30 queue.notify();

31 }

32 }

33 queue.poll(); //每次移走队首元素

34 queue.notify();

35 System.out.println(“从队列取走一个元素,队列剩余”+queue.size()+”个元素”);

36 }

37 }

38 }

39 }

40

41 class Producer extends Thread{

42

43 @Override

44 public void run() {

45 produce();

46 }

47

48 private void produce() {

49 while(true){

50 synchronized (queue) {

51 while(queue.size() == queueSize){

52 try {

53 System.out.println(“队列满,等待有空余空间”);

54 queue.wait();

55 } catch (InterruptedException e) {

56 e.printStackTrace();

57 queue.notify();

58 }

59 }

60 queue.offer(1); //每次插入一个元素

61 queue.notify();

62 System.out.println(“向队列取中插入一个元素,队列剩余空间:”+(queueSize-queue.size()));

63 }

64 }

65 }

66 }

67 }

以上代码描述的是经典的“生产者-消费者”问题。Consumer类代表消费者,Producer类代表生产者。在生产者进行生产之前(对应第48行的produce方法),会获取queue的内部锁(monitor)。然后判断队列是否已满,若满了则无法再生产,所以在第54行调用queue.wait方法,从而等待在queue对象上。(释放了queue的内部锁)此时生产者能够能够获取queue的monitor从而进入第21行的consume方法,这样一来它就会通过第33行的queue.poll方法进行消费,于是队列不再满了,接着它在第34行调用queue.notify方法来通知正在等待的生产者,生产者就会从刚才阻塞的wait方法(第54行)中返回。

同理,当队列空时,消费者也会等待(第27行)生产者来唤醒(第61行)。

await方法和signal/signalAll方法是wait方法和notify/notifyAll方法的升级版,在后文中会具体介绍它们与wait、notify/notifyAll之间的关系。

如何保证线程安全

所谓线程安全,指的是当多个线程并发访问数据对象时,不会造成对数据对象的“破坏”。保证线程安全的一个基本思路就是让访问同一个数据对象的多个线程进行“排队”,一个接一个的来,这样就不会对数据造成破坏,但带来的代价是降低了并发性。

race condition(竟争条件)

当两个或两个以上的线程同时修改同一数据对象时,可能会产生不正确的结果,我们称这个时候存在一个竞争条件(race condition)。在多线程程序中,我们必须要充分考虑到多个线程同时访问一个数据时可能出现的各种情况,确保对数据进行同步存取,以防止错误结果的产生。请考虑以下代码:

public class Counter {

private long count = 0;

public void add(long value) {

this.count = this.count + value;

}

}

我们注意一下改变count值的那一行,通常这个操作不是一步完成的,它大概分为以下三步:

第一步,把count的值加载到寄存器中;

第二步,把相应寄存器的值加上value的值;

第三步,把寄存器的值写回count变量。

我们可以编译以上代码然后用javap查看下编译器为我们生成的字节码:

我们可以看到,大致过程和我们以上描述的基本一样。那么我们考虑下面这样一个场景:假设count的初值为0,首先线程A加载了count到寄存器中,并且加上了1,而就当它要写回之前,线程B进入了add方法,它加载了count到寄存器中(由于此时线程A还没有把count写回,因此count还是0),并加上了2,然后线程B写回了count。在线程B完成了写回后,线程调度程序调度了线程A,线程A也写回了count。注意,此时count的值为1而不是我们希望的三。我们不希望一个线程在执行add方法时被其他线程打断,因为这会造成数据的破坏。我们希望的情况是这样的:线程A完整执行完毕add方法后,待count变量的值更新为1时,线程B开始执行add方法,在线程B完整执行完毕之前, 没有别的线程能够打断它,若有别的线程想调用add,也得等线程B执行完毕写回count值后。

像add这种方法代码所在的内存区,我们称之为临界区(critical area)。对于临界区,在同一时刻我们只希望有一个线程能够访问它,我们希望在一个线程进入临界区后把通往这个区的门“上锁”,离开后把门”解锁“,这样当一个线程执行临界区的代码时其他想要进来的线程只能在门外等着,这样可以保证了多个线程共享的数据不会被破坏。下面我们来介绍下为临界区“上锁”的方法。


桂林SEO半杯酒博客文章,转载请注明原文网址摘自 http://www.mna5.com/suibizatan/317.html,谢谢配合!

阅读延展

微信扫一扫,关注我们
1
3