并发编程简介

并发编程式Java语言的重要特性之一,当然也是最难以掌握的内容。编写可靠的并发程序是一项不小的挑战。但是,作为程序员的我们,要变得更有价值,就需要啃一些硬骨头了。因此,理解并发编程的基础理论和编程实践,让自己变得更值钱吧。

使用并发编程的优势

1、充分利用多核CPU的处理能力

现在,多核CPU已经非常普遍了,普通的家用PC基本都双核、四核的,何况企业用的服务器了。如果程序中只有一个线程在运行,则最多也只能利用一个CPU资源啊,如果是一个四核的系统,岂不是最多只利用了25%的CPU资源吗?严重的浪费啊!

另外如果存在I/O操作的话,单线程的程序在I/O完成之前只能等着了,处理器完成处于空闲状态,这样能处理的请求数量就很低了。换成多线程就不一样了,一个线程在I/O的时候,另一个线程可以继续运行,处理请求啊,这样,吞吐量就上来了。

2、方便业务建模

如果在程序中只包含一种类型的任务,那么比包含多种不同类型的任务的程序要更易于编写、错误更少,也更容易测试。如果在业务建模中,有多种类型的任务场景。我们可以使用多线程来解决,让每个线程专门负责一种类型的任务。

通过使用线程,可以将负责并且一步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。

并发编程带来的风险

虽然并发编程帮助我们提高了程序的性能,同时也提高对我们程序员的要求,因为在编写并发程序的过程中,一不小心就面临着多线程带来的风险。这些风险主要是安全性问题、活跃性问题和性能问题。

1、安全性问题

安全性问题可能是非常复杂的,在多线程场景中,如果没有正确地使用同步机制,会导致程序结果的不确定性,这是非常危险的。

比如我们熟知的 count++



public



上面的代码,在单线程环境中没有问题。但是如果是多个线程同时访问getCount方法,则不会得到期望的正确结果。

原因在于count ++ 不是CPU级别的原子指令,我们写了一条语句,但是在底层实际上包含了三个独立的操作:读取count,将count加1,将计算结果再写会主内存。而这多个线程有机会在其中任何一个操作时发生切换,这样便有可能两个线程拿到了同样的值,让后执行加1的操作。

2、活跃性问题

当某个操作无法继续执行下去的时候,就会发生活跃性问题。在串行程序中,活跃性问题形式之一可能是无意中造成的无限循环。在多线程场景中,如果有线程A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么线程A将永远地等待下去。

多线程中的活跃性问题一般指的就是死锁、饥饿、活锁等。

3、性能问题

本来是用多线程是为了提高程序性能的,结果却产生了性能问题。性能问题包括多个方面,例如服务时间过长,响应不灵敏,吞吐量过地、资源消耗过高等。

使用多线程而产生性能问题的根本原因就是,创建线程、切换线程都是要带来某种运行时开销的。如果我们的程序在频繁的创建线程,那很快创建线程的消耗将增加,拖累程序整体性能。同时频繁的线程切换,也会产生性能问题。

创建线程的几种方法

在使用Java开始编写并发程序时,我们首先要知道在Java中应该如何创建线程,至少有下面的三种方法。通过线程池创建线程留到后面线程池章节单独说明。

实现Runnable接口

我们通过实现一个Runnable接口,将线程要执行的任务封装起来。



public



使用Thread对象启动线程



public



实现Callable接口

可以看到实现Runnable接口启动的线程是没有返回值的。而Callable接口可以实现有返回值地启动线程。



public



通过FutureTask 我们可以获取到返回值



public



继承Thread类

继承Thread类,重写run方法。



public



一般不建议通过这种方式创建线程,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

线程安全性问题

我们编写并发程序,最先要考虑的就是安全性问题,要保证在多线程执行条件下程序运行结果的正确性。

要编写线程安全的代码,其核心就是要对状态访问的操作进行管理,特别是对共享的和可变的状态的访问。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但是“同步”这个术语还包括volatile类型的变量,显示锁(Lock)以及原子变量。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

原子性

这里的原子性其实和数据库事务中的原子性意义是相同的。我们把不可分割的一组操作叫做原子操作,这种不能中断的特性叫做原子性。

例如上面提到的 count++ 的问题,在java语法上,这看上去是一条指令,其实在CPU层面,至少需要三条CPU指令,因此,对CPU而言,count++的操作不是一个原子操作。




java 高并发学习 java高并发编程_java 高并发学习


CPU能保证的原子操作是CPU指令级别的,而不是高级语言的一条语句。

竞态条件

在并发编程中,因为线程切换,导致不恰当的执行时序而出现不正确结果的情况,叫做竞态条件。上面的count++ 的例子中就存在着竞态条件。

最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。

因此在并发编程实践中,要避免竞态条件的发生,才能保证线程安全性。

加锁机制

原子性问题的源头是线程切换,而操作系统做线程切换是依赖CPU中断的,所以禁止CPU中断就能够禁止线程切换。但是禁止线程切换就能保证原子性吗?

答案是并不能,例如在多核32位操作系统下,执行long类型变量的写操作。因为long类型变量是64位,在32位CPU上执行写操作会被拆分成两次写操作(写高32位和写低32位)。


java 高并发学习 java高并发编程_java 高并发学习_02


可能会出现,在同一时刻,一个线程A在CPU-1上执行写高32位指令,另一个线程B在CPU-2上也在执行写高32位指令,这样就会出现诡异的bug。此时,禁止CPU中断并不能保证同一时刻只有一个线程执行。


java 高并发学习 java高并发编程_多线程_03


因此,我们要有一种机制,保证同一时刻只有一个线程执行。我们称之为“互斥”。

简易锁模型

根据互斥特性,我们可以尝试构建一种简易的锁模型。


java 高并发学习 java高并发编程_多线程_04


通过加锁的操作,使得同一时刻,只有一个线程在执行临界区的代码。

Java语言提供的内置锁技术:sychronized

Java语言提供了关键字synchronized,就是一种锁的实现。准确的来说,这种实现是JVM帮我们实现的。

synchronized关键字可以用来修饰方法,也可以用来修饰代码块。基本的使用如下:


public


这里有一个 类锁和对象锁的概念,比如上面修饰静态方法的synchronized,是以SyncDemo.class 类为锁对象的,而修饰普通实例方法的synchronized,是以当前实例对象为锁对象的。

synchronized是一种内置锁,线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。另外synchronized还是一种互斥锁,互斥意味着当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果线程B永远不释放锁,那么线程A也将永远地等待下去。

内置锁synchronized是可以重(chong)入的。

如果内置锁不是可重入的,那下面的代码将会发生死锁。因为Payment和AliPayment的doService()方法都是synchronized的,因此每个方法在执行前都会获取Payment上的锁,如果内置不是可重入的,那么在执行super.doService()方法时,将无法获得锁,因为这个锁已经被持有,从而AliPayment方法就不会结束,从而也不会释放锁,线程将永远等待下去。


public


基础线程机制

线程的生命周期(6种状态)

通过查看Thread源码,我们可以知道线程总共有6中状态。


java 高并发学习 java高并发编程_多线程_05


一个线程只能处于一种状态,线程在这几种状态之间的转换便构成了线程的生命周期。


java 高并发学习 java高并发编程_java 高并发学习_06


这张图需要熟记于胸,面试高频题。

sleep和join,yield

sleep是Thread类的一个静态方法,它让当前正在执行的线程睡眠指定的毫秒数。


public


需要注意的是sleep方法会抛出InterruptedException 中断异常。

join方法:

一个线程可以在其他线程之上调用join方法,其作用是等待一段时间直到第二个线程运行结束才继续执行。

通过看join的源码可以知道,join的底层其实是在调用wait方法实现线程协作的。


public


yield方法:

是一种给线程调度器的暗示:让当前线程让出CPU的使用权,让其他线程执行一会。不过这种暗示没有任何机制保证它将会被采纳。

关于sleep、join、yield,可以移步下面这篇进一步了解。 Java多线程中join、yield、sleep方法详解

线程的优先级

通过setPriority方法,我们可以设置线程的优先级。线程的优先级将该线程的重要性传递给了调度器。尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先级高的线程先执行。然而,这并不是意味着优先级低的线程将得不到执行。优先级低的线程仅仅是执行的频率较低而已。

需要注意的是试图通过控制线程的优先级来控制线程的执行顺序,这是完全错误的做法。

对象的共享

只有正确地共享和发布对象,才能保证多线程同时访问的安全性。

可见性问题(Volatile变量)

什么是可见性问题。举个例子,当读操作和写操作在不同的线程中执行时,我们无法保证执行读操作的线程能够及时地看到写线程写入的值。这就是可见性问题。

下面的程序说明了当多个线程在没有同步的情况下共享数据会出现的问题。


public


在代码中,主线程和读线程都在访问共享的变量ready和number。主线程启动读线程,然后将number设置为42,并将ready设置为true。读线程则一直循环直到发现ready为true时,然后输出number的值。我们期望是输出42。但事实上有可能输出0,或者程序根本无法终止。这是因为代码中没有使用足够的同步机制,无法保证主线程修改的number和ready值对于读线程来说是可见的。

重排序:

上面程序可能会输出0,即读线程看到了ready的值,但却没有看到之后写入的number的值(代码中却是先写入number,再写入ready,顺序变了),这种现象叫做“重排序”。

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。

volatile变量:

volatile变量是java语言提供的一种稍弱的同步机制(比起synchronized锁而言),用来确保将变量的更新操作通知到其他线程。

当把变量申明成volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

在访问volatile类型的变量时不会执行加锁的操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

加锁机制既可以保证可见性又可以保证原子性,但是volatile变量只能确保可见性。

当且仅当满足一下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变形条件中。
  • 在访问变量时不需要加锁。

发布和逸出

“发布”一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。

在许多情况下,我们需要确保对象及其内容状态不被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时要确保线程安全性,则可能需要同步。

当某个不应该发布的对象被发布时,这种情况就被称为逸出。

下面是一个发布的例子:


public


线程封闭

前面说到,当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在但线程内访问数据,就不需要使用同步。这种技术被称为线程封闭

当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不会线程安全的。

那在具体的编程实践中,该如何实现线程封闭呢,其实可以通过局部变量或ThreadLocal类等。

不变性(Final域)

满足同步需求的另一种方法是使用不可变对象。我们目前为止探讨的所有原子性和可见性的问题,都和多线程访问可变的状态相关。如果这个对象本身的状态不会发生任何改变,那这些复杂性都消失了。我们也不需要同步机制了。

如果某个对象在被创建后其状态就不能被修改,那么这个对象就称为不可变对象。而不可变对象一定是线程安全的。

不可变性并不等于将对象中的所有域都声明为final的,即使都声明为final类型的,这个对象也仍然是可变的,因为在final域中可以保存对可变对象的引用。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型。
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)。

final域

final域是不能被修改的(但如果final域引用的是可变对像,那么这些被引用的对象是可以修改的)。然而,在Java内存模型中,final域还有这特殊的语义。final域能确保初始化过程的安全性,从而可以不受限制地访问不可变对象,并在共享这些对象时无需同步。

一种好的编程习惯是,除非需要某个域是可变的,否则应将其声明为final域。

取消与关闭

一般来说,我们启动一个线程,然后等着它自然 运行结束就完了。但是可能存在这样一种需求,我们有时候想提前结束任务或者线程。比如用户点了取消按钮,需要快速关闭某个应用等。

取消某个操作的原因可能有很多:

  • 用户请求取消。比如点击了图形界面的取消按钮。
  • 有时间限制的操作。当达到超时时间设置时必须取消正在进行的任务。
  • 因为产生错误了,需要取消正在进行的任务。

Java语言早期版本中可能存在Thread.stop和suspend等方法去终止一个线程,但这些方法因为安全性问题都已经被废弃了。

我们一般能想到的去终止一个线程的方法,可能是去检查一个volatile类型的boolean值,通过改变boolean值让线程停下来。像下面这样:


public


如果任务中调用了一些阻塞方法,比如从磁盘或是网络读取字节流。则通过检查标志位的方式取消或者结束任务将变得不可行了,因为存在可能永远不会检查标志位的情况,这样任务任务永远不会结束了。

在Java线程中提供了一种中断机制,能够使一个线程终止另一个线程的当前工作。

中断

线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前的工作,并转而执行其他的工作。

每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。

Thread类中包含了和线程中断相关的3个方法:


public


阻塞方法,比如Thread.sleep和Object.wait()等,都会检查线程何时中断,并在发现中断时提前返回。它们在响应中断时执行的操作包括:清理中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。因此,这些阻塞方法抛出InterruptedException就是提供给程序员一种让程序停止的入口。


public


JVM关闭

JVM可以正常关闭,也可以强行关闭。正常关闭的方式主要有:当最后一个“正常(非守护)”线程结束时,或者当调用了System.exit时,或者通过其他特定平台的方法关闭。强行关闭方式可以是通过调用Runtime.halt或者在操作系统中“杀死”JVM进程。

关闭钩子

在正常关闭中,JVM首先会调用所有已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程。

在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后在停止。当被强行关闭时,只是关闭JVM,而不会运行关闭钩子。

守护线程

有时候后你希望创建一个线程来执行一些辅助工作,但是又不希望这个线程阻碍JVM的正常关闭。这种情况下可以使用守护线程。

线程分为两种:普通线程和守护线程。JVM在启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(比如垃圾回收线程)。我们平时在代码中创建的线程是普通线程,因为新建的线程会继承创建它的线程的守护状态。

普通线程和守护线程的主要区别在当线程退出的时候发生的操作。当JVM停止时,所有仍然存在的守护线程都将抛弃——既不会执行finally代码块,也不会执行回卷栈,而JVM只是直接退出。

线程协作

当使用多线程同时运行多个任务时,我们通过加锁(互斥锁)的方式实现了多个任务的同步,解决了任务之间干涉问题,其本质是在解决安全性问题。线程协作则是在同步基础上要多个任务之间有协调,也就是说有些任务之间是有先后执行顺序的,一个任务结束了,或者一些任务准备好了,才能执行接下来的任务。

这种协作,首先是建立在互斥的基础上的。这也就是为什么wait()和notify()方法必须要在同步代码块之中了。

wait和notify

当一个任务在方法里遇到了对wait()的调用的时候,当前执行的线程将被挂起,对象上的锁被释放,因为wait()方法释放了锁,这就意味着另一个任务可以获得锁,因此在该对象中的其他synchronized方法可以在wait()期间被调用。而其他方法中一般会使用notify()或者notifyAll()来重新唤起等待的线程。

wait()有两种形式,其中一种是带毫秒参数的重载方法,含义和sleep()方法里参数的意思相同,都是指“在此期间暂停”。但和sleep不同的是,对于wait()方法而言:

  • 在wait()期间对象锁是释放的。
  • 可以通过notify、notifyAll或者时间到期了,从wait()中恢复执行。

这里引用《Java编程思想》中的给汽车打蜡的例子来做说明。

WaxOMatic.java有两个过程:一个是将蜡涂到Car上,一个是抛光它。涂蜡之前要先抛光。即抛光-->涂蜡 -->抛光-->涂蜡.....。这样一个交替的过程。


public


运行结果如下:


Wax


notify和notifyAll的区别

可能有多个任务在单个Car对象上处于wait状态,因此调用notifyAll()比只调用notify()要更安全。在使用notify()时,在众多等待的线程中只能有一个被唤醒。如果所有这些任务在等待不同的条件,那么你就不会知道是否唤醒了恰当的任务。因此应尽量多地使用notifyAll,它总是没错的。

另外需要注意的是,当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。

生产者-消费者模型

下面的例子是通过wait和notify协作机制实现的生产者-消费者模型。示例中生产者生产面包,消费者消费面包。


public


运行结果:


生产了一个面包Bread:


未完待续