title: 并发
tag: 并发的知识
categories: 分类
comment: 是否允许评论(true or false)
description: 描述
top_img: https://z3.ax1x.com/2021/10/06/4xq2s1.png
cover: https://z3.ax1x.com/2021/10/06/4xq2s1.png
人们往往都有多CPU的计算机,但是,并发执行的进程数目并不受限于CPU数目。操作系统会为每个进程分配CPU时间片,给人并行处理的感觉。
多线程程序在更低一层扩展了多任务的概念:单个程序看起来在同时完成多个任务。每个任务在一个线程中执行,线程是控制线程的简称。如果一个程序可以同时运行多个线程,则称这个程序时多线程的。、
那么,多进程与多线程有哪些区别呢。本质的区别在于每个进程都拥有自己的一套变量,则线程共享数据。在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
在实际应用中,多线程非常有用。例如,一个浏览器可以同时下载几幅图片。一个Web服务器需要同时服务并发的请求。
什么是线程
我们先来看两个线程的简单程序。这个程序可以银行账户之间完成资金转账。我们使用了一个Bank类,它可以存储给定数目的账户的余额。transfer方法将一定金额从一个账户转移到另一个账户。
在第一个线程中,我们将钱从账户0转移到账户1.第二个线程将从账户2转移到账户3。
在一个单独的线程中运行一个任务的简单过程:
1.将执行这个任务的代码放在一个类的run方法中,这个类要实现Runnable接口。Runnable接口非常简单,只有一个方法:
public interface Runnable{
void run();
}
// 由于Runnable是一个函数式接口,可以用一个lambda表达式创建一个实例:
Runnable r = () -> {task code};
2.从这个Runnable构造一个Thread对象:
var t = new Thread(r);
3.启动线程
t.start();
为了建立单独的线程来完成转账,我们只需要把转账代码放在一个Runnable的run方法中,然后启动一个线程:
Runnable r = ()->{
try
{
for(int i = 0; i < STEPS; i++){
double amount = MAX_AMOUT*Math.random();
bank.transfer(0,1,amount);
Thread.sleep((int)(DEALY*Math.random()))
}
}catch(InterruptedException e){
}
};
var t = new Thread(r);
t.start();
对于给定的步骤数。这个线程会转账一个随机金额,然后休眠一个随机的延迟时间。
我们需要捕获sleep方法有可能抛出的InterruptedException异常,一般来说,中断用来请求终止一个线程。相应地,出现InterruptedException时,run方法会退出。
程序还会启动第二个线程,它从账户2向账户3转账。运行这个程序时,可以得到类似这样的输出:
可以看到,两个线程的输出时交错的,这说明它们在并发运行。实际上,两个输出行交错显示时,输出有时会有些混乱。
class MyThread extends Thread{
public void run(){
task code
}
}
// 可以通过建立Thread类的一个子类来定义线程。
// 然后可以构造这个子类的一个对象,并调用它的start方法。但是,现在不在推荐这种方法。
不要调用Thread类或Runnable对象的run方法。直接调用run方法只会在同一个线程中执行这个任务–而没有启动新的线程。实际上,应当调用Thread.start,这会创建一个执行run方法的新线程。
java.lang.Thread
Thread(Runnable target)
// 构造一个新线程,调用指定目标的run()方法
void start()
// 启动这个线程,从而调用run()方法。这个方法会立即返回。新线程会并发运行
void run()
// 调用相关Runnable的run方法
static void sleep(long millis)
// 休眠指定的毫秒数
java.lang.Runnable
void run()
// 必须覆盖这个方法,提供你希望的指令
线程状态
线程可以有以下6种状态:
- New(新建)
- Runnable(可运行)
- Blocked(阻塞)
- Waiting(等待)
- Timed waiting(计时等待)
- Terminated(终止)
要确定一个线程的状态,只需要调用getState方法。
新建线程
当用new操作符创建一个新线程时,如new Thread®,这个线程还没有开始运行。这意味着它的状态是新建。当一个线程处于新建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
可运行线程
一旦调用start方法,线程就处于可运行状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。
一旦一个线程开始运行,它不一定始终保持运行。事实上,运行中的线程有时需要暂停,让其他线程有机会运行。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完时,操作系统剥夺该线程的运行权,并给另一线程一个机会来运行。当选择下一个线程时,操作系统会考虑线程的优先级。
现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度,在这样的设备中,一个线程只有在调用yield方法或被阻塞或等待时才失去控制权。
在有多个处理器的机器上,每一个运行器运行一个线程,可以有多个线程并行运行。如果线程的数目多于处理器的数目,调度器还是需要分配时间片。
在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(正是因为这样,这个状态称为“可运行”而不是“运行”)。
static void yield()
// 使当前正在执行的线程向另一个线程交出运行权。注意这是一个静态方法。
阻塞和等待线程
当线程处于阻塞或等待状态时,它暂时是不活动的。它不运行任何代码,而且消耗最少的资源。要由线程调度器重写激活这个线程。
- 当一个线程试图获取一个内部的对象锁,而且这个锁目前被其它线程占有,该线程就会被阻塞。当所有其他线程都释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将会变成非阻塞状态。
- 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入等待状态。调用Object.wait方法或Thread.join方法,java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,阻塞状态与等待状态并没有太大区别。
- 有几个方法有超时参数,调用这些方法会让线程进入计时等待状态。这一状态将一直保持到超时期或者接收到适当的通知。
当一个线程阻塞或等待时,可以调度另一个线程运行。当一个线程被重新激活,调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器会剥夺某个当前运行线程的运行权,选择一个新线程运行。
终止线程
线程会由于以下两个原因之一而终止:
- run方法正常退出,线程自然就终止
- 因为一个没有捕获的异常终止了run方法,使得线程意外终止。
java.lang.Thread
void join()
// 等待终止指定的线程
void join(long millis)
// 等待指定的线程终止或者等待经过指定的毫秒数
Thread.State getState()
// 得到这个线程的状态:取值为NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING或TERMINATED。
线程属性
下面几节将讨论线程的各种属性,包括中断的状态、守护线程、未捕获异常的处理器以及不应使用的一些遗留特性。
中断线程
当线程的run方法执行方法体中最后一条语句后在执行return语句返回时,或者出现了方法中没有捕获的异常时,线程将终止。
除了已经废弃的stop方法,没有办法可以强制线程终止。不过,可以使用interrupt方法可以用来请求终止一个线程。
当对一个线程调用interrupt方法时,就会设置线程的中断状态。这是每个线程都有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想得出是否设置了中断状态,首先调用静态的Thread,currentThread方法获得当前线程,然后调用isInterrupted方法:
while(!Thread.currentThrad().isInterrupted()&&more work to do){
do some work
}
没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应该处理这个异常,然后在继续执行。但是,更普遍的是,线程只希望将中断解释为终止一个请求。这种线程的run方法具有如下形式。
Runnable r = () ->{
try{
...
while(!Thread.currentThread().isInterrupted()&&more work to do){
do some work
}
}
catch(InterruptedException e){
// thread was interrupted during sleep or wait
}
finally{
cleanup,if required
}
// exiting the run method terminates the thread
};
如果你的循环调用了sleep,不要检测中断状态,而应当捕获InterruptedException异常,如下:
Runnable r = ()->{
try{
...
while(more work to do){
do some work
Thread.sleep(delay);
}
}catch(InterruptedException e){
// thread was interrupted during sleep
}finally{
cleanup,if required
}
// exiting the run method terminates the thread
}
有两个非常类似的方法,interrupted和isInterrupted。interrupted方法是一个静态方法,它检查当前线程是否被中断,而且,调用interrupted方法会清除该线程中的中断状态。另一个方面,isInterrupted方法是一个实例方法,可以用来检查是否有线程被中断。调用这个方法不会改变中断状态。
你可能会发现很多发布的代码在底层抑制了InterruptedException异常,如下所示:
void mySubTask(){
...
try{sleep(delay);}
catch(InterruptedException e){ } // don't ignore
...
}
不要像上面这样做,如果想不出在catch子句中可以做什么有意义的工作,仍然有两种合理的选择:
-
在catch子句中调用Thread.currentThread().interrupt()来设置中断状态。这样一来调用者就可以检测中断状态。
void mySubTask(){ ... try{sleep(delay);} catch(InterruptedException e){ Thread.currentThread().interrupt();} // don't ignore ... }
-
或者,更好的选择是,用throws InterruptedException标记你的方法,去掉try语句块。这样一来调用者就可以捕获这个异常
void mySubTask() throws InterruptedException{ ... sleep(delay); ... }
Java.lang.Thread
void interrupt() // 向线程发送中断请求。线程的中断的状态将被设置为true.如果当前该线程被一个sleep调用阻塞,则抛出一个InterruptedException异常。 static boolean interrupted() // 测试当前线程(即正在执行这个指令的线程)是否被中断。注意,这是一个静态方法。这个调用有一个副作用--它将当前线程的中断状态重置为false. boolean isInterrupted() // 测试线程是否被中断。与static interrupted方法不同,这个调用不改变线程的中断状态、 static Thread currentThread() // 返回表示当前正在执行的线程Thread对象。
守护线程
可以通过调用
t.setDaemon(true);
将一个线程转换为守护线程。这样一个线程并没有什么魔力。守护线程的唯一用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就会退出。因为如果只剩下守护线程,就没必要继续运行程序了。
线程名
默认情况下,线程有容易记的名字,如Thread-2.可以用setName方法为线程设置任何名字:
var t = new Thread(runnable); t.setName("Web crawler");
线程优先级
在java程序设计语言中,每一个线程有一个优先级。默认情况下,一个线程会继承构造它的那个线程的优先级。可以用setPriority方法提高或降低任何一个线程的优先级。可以将优先级设置未MIN_PRIORIT与MAX_PRIORITY之间的任何值。NORM_PRIORITY定义为5.
java.lang.Thread
void setPriority(int newPriority)
// 设置线程的优先级。优先级必须在Thread.MIN_PRIORITY与Thread.MAX_PRIORITY之间。一般使用Thrad.NORM_PRIORITY优先级。
static int MIN_PRIORITY
// 是Thread可以有的最小优先级。最小优先级的值为1.
static int NORM_PRIORITY
// Thread的默认优先级。默认优先级为5。
static int MAX_PRIORITY
// 是Thread可以有的最大优先级。最大优先级的值为10.
同步
如果两个线程存取一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发送什么呢?可以想见,这两个线程会互相覆盖。取决于线程访问数据的次序,可能会导致对象被破坏。这种情况通常称为竞态条件。
竞态条件的一个例子
为了避免对线程破坏共享数据,必须学习如何同步存取。如果没有同步存储会发生什么。
下面来模拟我们的银行,我们要随机第从哪个源账户转账到哪个目标账户。
public void transfer(int from,int to,double amount){
accounts[form] -= amount;
accounts[to] += amount;
}
下面是Runnable实例的代码。run方法不断地从一个给定银行账户取钱。在每次迭代中run方法选择一个随机的目标账户和一个随机金额,调用bank对象的transfer方法,然后休眠。
Runnable r = () -> {
try{
while(true){
int toAccount = (int) (bank.size()*Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount,toAccount,amount);
Thread.sleep((int)(DELAY*Math.random()));
}
}catch(InterruptedException e){
}
};
刚开始时交易时正确的,但是后来发现银行的余额不对。
具体代码:
public class Bank {
private final double[] accounts;
public Bank(int n, double initialBalance){
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
public void transfer(int from,int to,double amount){
if (accounts[from] < amount) return;
System.out.println(Thread.currentThread());
accounts[from] -= amount;
accounts[to] += amount;
System.out.print(getToBalance());
}
public double getToBalance(){
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
public int size(){
return accounts.length;
}
}
public class BankTest {
public static final int NACCOUNTS = 100;
public static final double INITIAL_BALANCE = 100;
public static final double MAX_AMOUNT = 1000;
public static final int DELAY = 10;
public static void main(String[] args) {
Bank bank = new Bank(NACCOUNTS, INITIAL_BALANCE);
for (int i = 0; i < NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = () -> {
try{
while (true){
int toAccount = (int) (bank.size()*Math.random());
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount,toAccount,amount);
Thread.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread t = new Thread(r);
t.start();
}
}
}
竞态条件详解
运行上面的程序,一段时间后,不知不觉地出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进帐。当两个线程试图同时更新同一个账户时,就会出现这个问题。假设两个线程同时执行指令
accounts[to] += amount;
问题在于这不是原子操作。这个指令可能如下处理:
1.将accounts[to]加载到寄存器
2.增加amount
3.将结果写回accounts[to];
现在,假定第1个线程执行步骤1和2,然后,它的运行权被抢占。在假设第2个线程被唤醒,更新account数组中的同一个元素。然后,第一个线程被唤醒并完成其第3步。
这个动作会抹去第2个线程所作的更新,这样一来,总的金额就不对了。
锁对象
有两种机制可以防止并发代码块。Java语言提供了一个synchronized关键字来达到这一个目的。synchronized关键字会自动提供有一个锁以及相关的“条件”,对于大多数需要显示锁的情况,这种机制很强大,也很便利。在我们分别了解了锁和条件的内容之后,就能更加容易地理解synchronized关键字。java.util.concurrent框架为这些基础机制提供了单独的类。
用ReentrantLock保护代码块的基本结构如下:
myLock.lock(); // a ReentrantLock object
try{
critial section
}
finally{
muLock.unlock(); // make sure the lock is unlock even if an exception is thrown
}
这个结构确保任何时候只有一个线程进入临界区。一旦一个线程锁定了对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会暂停,直到第一个线程释放这个锁对象。
注意:要把unlock操作包括在fianlly子句中,这一点很重要。如果在临界区的代码抛出一个异常,锁必须释放。否则,其他线程将永远阻塞。
下面使用一个锁来保护Bank类的transfer方法:
public class Bank
{
private var bankLock = new ReetrantLock();
...
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try
{
System.out.println(Thread.currentThread());
accounts[from] -= amount;
accounts[to] += amount;
System.out.print(getToBalance());
}
finally{
bankLock.unlock();
}
}
}
假设一个线程调用了一个transfer,但是在执行结束前被抢占。在假设第一个线程也调用了transfer.由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它会暂停,必须等待了第一个线程执行完transfer方法。当第一个线程释放锁时,第二个线程才能开始运行。
如果我们将锁代码增加到transfer方法并再次运行程序。这个程序可以一直运行下去,银行余额绝对不会有错误。
注意每个Bank对象都有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁可以用来保证串性化访问。但是,如果两个线程访问不同的对象,每个线程会得到不同的锁对象,两个线程都不会阻塞。因为线程在操纵不同的Bank实例时,线程之间不会相互影响。
这个锁称为重入(reentrant)锁,因为线程可以反复获得已拥有的锁。锁有一个持有计数来跟踪对lock方法的嵌套调用。线程每一次调用lock后都要unlock来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。
例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的计数持有为2.当getTotalBalance方法退出时,持有计数变为1.transfer方法退出时候,持有计数变为0,线程释放锁。
通常我们可能希望保护会更新或检查共享对象的代码块,从而能确信当前操作执行完之后其他线程才能使用同一个对象。
java.lang.concurrent.locks.lock
void lock()
// 获得这个锁,如果锁当前被另一个线程占有,则阻塞
void unlock()
// 释放这个锁
java.util.concurrent.locks.lock.ReentrantLock
ReentrantLock()
// 构造一个重入锁,可以用来保护临界区
ReetrantLock(boolean fair)
// 构造一个采用公平策略的锁。一个公平锁倾向于等待时间最长的线程。不过,这种公平保证可能严重影响性能。所以默认情况下,不要求锁是公平的。
条件对象
通常,线程进入临界区后却发现只有满足某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。这里我们会介绍Java库中条件对象的实现。
如果一个账户没有足够的资金转账,我们不希望这样的账户转出资金。注意不能使用下面的代码:
if(bank.getBalance(from) >= amount){
bank.transfer(from,to,amount);
}
在调用transfer之前,当前线程完全有可能被中断。
在线程再次运行之前,账户余额可能已经低于提款金额。必须确保在检查与转账活动之间没有其他线程修改为余额。为此,可以使用一个锁来保护这个测试和转账操作:
public void transfer(int from,int to,int amount){
bankLock.lock();
try
{
while(accounts[from] < amount)
{
// wait
...
}
// transfer funds
...
}
finally
{
bankLock.unlock();
}
}
现在,当账户中没有足够资金时,我们应该干什么?我们需要等待,直到另一个线程向账户中增加了资金。但是,这个线程刚刚获得了bankLock的排他性访问权,因此别的线程没有存款的机会。这里就需要加入条件对象。
一个锁对象可以有一个或多个相关联的条件对象。你可以用newConditon方法获得一个条件对象。习惯上给每个条件对象一个合适的名字来反映它表示的条件。例如,在这里我们建立了一个条件对象来表示“资金充足”条件。
class Bank
{
private Conditon sufficientFunds;
...
public Bank(){
...
sufficientFunds = bnakLock.newCondition();
}
}
如果trandsfer方法发现资金不足,它会调用
sufficientFunds.await();
当前线程现在暂停,并放弃锁。这就允许另一个线程执行,我们希望它能增加账户余额。
等待获得锁的线程和已经调用了await方法的线程存在本质上的不同。一旦一个线程调用了await方法,它就进入这个条件的等待集。当锁可用时,该线程并不会变为可运行状态。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用signalAll方法。
当另一个线程完成转账时,它应该调用
sufficientFunds.signalAll();
这个调用会重新激活等待这个条件的所有线程。当这些线程从等待集中移出时,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await调用返回,得到这个锁,并从之前暂停的地方继续执行。
最终需要有某个其他线程调用signalAll方法,这一点至关重要。当一个线程调用await时,它没有办法重写自行激活。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁现象。如果所有其他线程都被阻塞,最后一个线程调用了await方法但没有先解除另外某个线程的阻塞,现在这个线程也会阻塞。此时没有线程可以解除其他线程的阻塞状态,程序会永远挂起。
应该什么时候调用signalAll呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll.如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额。在这个例子中,完成转账时,我们就会调用signalAll方法。
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try
{
while(accounts[from] < amount)
{
sufficientFunds.await();
// transfer funds
...
sufficientFunds.signalAll();
}
}
finally{
bankLock.lock();
}
}
注意signalAll调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁之后竞争访问对象。
另一个方法signal只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。这比解除所有线程的阻塞更加高效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,它就会再次阻塞。如果没有其他线程再次调用signal,系统就会进入死锁。
/**
* 文件描述
*
* @Author: QJS
* @CreateDate: 2021/12/24 20:15
**/
public class Bank {
private final double[] accounts;
private Lock bankLock;
private Condition sufficentFunds;
public Bank(int n, double initialBalance){
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
bankLock = new ReentrantLock();
sufficentFunds = bankLock.newCondition();
}
public void transfer(int from,int to,double amount)throws InterruptedException{
bankLock.lock();
try{
while (accounts[from] < amount)
sufficentFunds.await();
System.out.println(Thread.currentThread());
accounts[from] -= amount;
accounts[to] += amount;
System.out.print(getToBalance());
sufficentFunds.signalAll();
}finally {
bankLock.unlock();
}
}
public double getToBalance(){
bankLock.lock();
try{
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}finally {
bankLock.unlock();
}
}
public int size(){
return accounts.length;
}
}
/**
* 文件描述
*
* @Author: QJS
* @CreateDate: 2021/12/24 20:30
**/
public class BankTest {
public static final int NACCOUNTS = 100;
public static final double INITIAL_BALANCE = 100;
public static final double MAX_AMOUNT = 1000;
public static final int DELAY = 10;
public static void main(String[] args) {
Bank bank = new Bank(NACCOUNTS, INITIAL_BALANCE);
for (int i = 0; i < NACCOUNTS; i++) {
int fromAccount = i;
Runnable r = () -> {
try{
while (true){
int toAccount = (int) (bank.size()*Math.random());
// 转移总的金额数
double amount = MAX_AMOUNT * Math.random();
bank.transfer(fromAccount,toAccount,amount);
Thread.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread t = new Thread(r);
t.start();
}
}
}
API
java.util.concurrent.locks.Lock
Condition newCondition()
// 返回一个与这个锁相关联的条件对象
java.util.concurrent.locks.Condition
void await()
// 将该线程放在这个条件的等待集中
void signalAll()
// 解除该条件集中所有线程的阻塞状态
void signal()
// 从该条件的等待集中随机选择一个线程,解除其阻塞状态。
synchronzied关键字
之前我们已经了解了如何使用Lock和Condition对象。在进一步深入之前,先对锁和条件的要点进行一个总结:
- 锁用来保护代码片段,一次只能有一个线程执行被保护的代码。
- 锁可以管理试图进入被保护代码段的线程
- 一个锁可以有一个或多个相关联的条件对象
- 每个条件对象管理那些已经进入被保护代码但还不能运行的线程。
如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法。也就是说,要调用这个方法,线程必须获得内部对象锁。
public synchroized void method(){
method body
}
等价于
public void method()
{
this.intrinsicLock.lock();
try{
method body
}
finally{
this.intrinsicLock.unlock();
}
}
可以简单地将transfer方法声明为synchronzied,而不必使用一个显式的锁。
内部对象锁只有一个关联条件。wait方法将一个线程增加到等待集中,notifyAll/notify方法可以解除等待线程的阻塞。换句话说,调用wait或notifyAll等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
例如,可以用Java如下实现Bank类:
class Bank
{
private double[] accounts;
public synchronized void transfer(int form, int to, int amount)throws InterruptedException
{
while(accounts[from] < amount) wait();
accounts[from] -= amount;
accounts[to] += amount;
notifyAll();
}
public syncronized double getTotalBalance();
}
使用synchronized关键字可以得到更为简洁的代码。要理解这个代码,必须知道每个对象都有一个内部锁,并且这个锁有一个内部条件。这个锁会管理试图进入synchronized方法的线程,这个条件可以管理调用了wait的线程。
将静态方法声明为同步也是合法的。如果调用这样一个方法,它会获得相关类对象的内部锁。如果一个Bank类有一个静态同步方法,那么当调用这个方法时,Bank.class对象的锁会锁定。因此,没有其他线程可以调用这个类的该方法或任何其他同步静态方法。
内部锁和条件存在一些限制。包括:
- 不能中断一个正在尝试获得锁的线程
- 不能指定尝试获得锁时的超时时间
- 每个锁仅有一个条件可能不够的
在代码中应该使用哪一种做法呢?Lock和Condition对象还是同步方法?下面是一些建议:
-
最好不使用Lock/Condition也不使用synchronized关键字。在许多情况下,可以使用java.util.concurrent包中的某种机制,它会为你处理所有的锁定。
-
如果synchronized关键字适合你的程序,那么尽量使用这种做法,这样可以减少编写的代码量,还能较少出错的概率。
java.lang.Object void notifyAll() // 解除在这个对象上调用wait方法的那些线程的阻塞状态。该方法只能在同步方法或同步块中调用。如果当前线程不是对象的所有者,该方法会抛出一个IllegalMoitorStateException. void notify() // 随机选择一个在这个对象上调用wait方法的线程,解除其阻塞状态。该方法只能在一个同步方法或同步块中调用。如果当前线程不是对象锁的所有者,该方法会抛出一个IllegalMonitorStateException异常。 void wait() // 导致一个线程进入等待状态,直到它得到通知。该方法只能在一个同步方法或同步块中调用。如果当前线程不是对象锁的所有者,该方法会抛出一个IllegalMonitorStateException异常。 void wait(long millis) void wait(long millos,int nanos) // 导致一个线程进入等待状态,直到它得到通知或者经过了指定的时间。这些方法只能在一个同步方法或同步块中调用。如果当前线程不是对象锁的所有者,这些方法会抛出 // IllegalMonitorStateException异常。
同步块
每一个java对象都有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁:即进入一个同步块。当线程进入如下形式的块时:
synchronized(obj){
critical section
}
// 它会获得obj的锁
// 有时我们会发现一些“专用”锁,如:
public class Bank{
private double[] accounts;
private var lock = new Object();
...
public void transfer(int from, int to ,int amount)
{
synchronized(lock)
{
accounts[from] -= amount;
accounts[to] += amount;
}
System.out.println(...);
}
}
// 在这里,创建lock对象只是为了使用每个Java对象拥有的锁
有时程序员使用一个对象的锁来实现额外的原子操作,这种做法称为客户端锁定。
监视器概念
锁和条件是实现线程同步的强大工具,但是,严格地来讲,它们不是面向对象的。研究人员找出一种希望不要求程序员考虑显示锁就可以保证多线程的安全性。最成功的解决方案就是监视器。用Java的术语来将,监视器有如下的特性:
- 监视器只包含私有字段的类
- 监视器类的每个对象有一个关联的锁
- 所有方法由这个锁锁定。换句话说,如果客户端调用obj.method(),那么obj对象的锁在方法调用开始时自动获得,并且当方法返回时自动释放锁。因为所有的字段是私有的,这样的安排可以确保一个线程处理字段时,没有其他线程能够访问这些字段。
- 锁可以有任意多个相关联的条件。
Java中的每一个对象都有一个内部锁和一个内部条件。如果一个方法使用synchronized关键字声明,那么,它表现得就像是一个监视器方法。可以通过调用wait/notifyAll/notify来访问条件变量
volatile字段
volatile关键字为实例字段的同步访问提供了一种免锁机制。如果声明一个字段为volatile,那么编译器和虚拟机就知道该字段可能被另一个线程并发更新。
假设一个对象有一个boolean标记done,它的值由一个线程设置,而由另一个线程查询,如同我们讨论过的那样,你可以使用锁:
private boolean done;
private synchronized boolean isDone()
{
return done;
}
private synchronized void setDone(){
done = true;
}
或许使用内部对象锁不是个好主意。如果另一个线程已经对该对象加锁,isDone和SetDone方法可能会阻塞。
这种情况下,将字段声明为volatile就很合适:
private volatile boolean done;
public boolean isDone(){
return done;
}
public void setDone(){
done = true;
}
编译器会插入适当的代码,以确保如果一个线程对done变量做了修改,这个修改对读取这个变量的所有其他线程都可见。
注意volatile字段不能提供原子性。
final变量
从之前可以了解到,除非使用锁或volatile修饰符,否则无法从多个线程安全地读取一个字段。还有一种情况可以安全地访问一个共享字段,即这个字段声明为final时。考虑以下声明
final var accounts = new HashMap<String,Double>();
其他线程会在构造器完成构造之后才看打这个accounts变量。
如果不使用final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到null,而不是新构造的HashMap.
当然,对这个映射的操作并不是线程安全的。如果有多个线程更改和读取这个映射,仍然需要进行同步。
原子性
假设对共享变量除了赋值之外并不做其他操作,那么可以将这些共享变量声明为volatile.
java.util.concurrent.atomic包中有很多类使用了高效的机制级指令类类保证其他操作的原子性。如,AtomicInteger类提供了incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数进行自增或自减。可以安全地生成一个数值序列,如下所示:
public static AtomicLong nextNumber = new AtomicLong();
// in some thread
long id = nextNumber.incrementAndGet();
incrementAndGet方法以原子方式将AtomicLong自增,并返回自增后的值。也就是说,获得值,增1并设置然后生成新值的操作不会中断。可以保证即使是多个线程并发地访问同一个实例,也会计算并返回正确的值。
有很多方法可以以原子方式设置和增减值,不过,如果希望完成更复杂的更新,就必须使用compareAndSet方法。如,假设希望跟踪不同线程观察的最大值。下面的代码是不可行的:
public static AtomicLong largest = new AtomicLong();
// in some thread...
largest.set(Math.max(largest.get(),observed)); // ERROR --race condition
这个更新不是原子的。实际上,可以提供一个Lambda表达式更新变量,它会为你完成更新。
largest.updateAndGet(x -> Math.max(x,observed));
或
largets.accumlateAndGet(Observed,Math::max);
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。LongAdder和LongAccumulator类解决了这个问题。LongAdder包括对个变量,其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。性能会有显著提升。
如果预期可能存在大量竞争,只需要使用LongAdder而不是AtomicLong.方法名稍有区别。要调用increment让计数器自增,或者调用add来增加一个量,另外调用sum来获取总和。
var adder = new LongAdder();
for(...)
pool.submit(() ->{
while(...){
...
if(...)adder.increment();
}
});
...
long total = adder.sum();
死锁
锁和条件不能解决多线程中可能出现的所有问题。考虑下面情况:
- 账户1:$200
- 账户2:$300
- 线程1:从账户1转$300到账户2
- 线程2:从账户2转$400到账户1
线程1和线程2都会被阻塞。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。
有可能会因为每一个线程需要等待更多的钱款存入而导致所有线程被阻塞。这样的状态被称为死锁。
线程局部变量
线程间共享变量的风险,有时可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例。如,SimpleDateFormat类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
结局可能很混乱,因为dateFormat使用的内部数据可能会并发的访问所破坏。
public static final ThredLcoal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dataStamp = dateFormat.get().format(new Date());
在一个给定线程中首次调用get时,会调用构造器中lambda表达式。在此之后,get方法会返回属于当前线程的那个实例。
可以使用ThreadLocal辅助类为各个线程提供一个单独的生成器,不过Java7还另外提供了一个便利类。只需要做以下调用:
int random = TreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()调用会返回特定于当前线程的Random类的实例。
API
T get()
// 得到这个线程的当前值。如果是首次调用get,会调用initialize来得到这个值
void set(T t)
// 为这个线程设置一个新值
void remove()
// 删除对应这个线程的值
static<S> ThradLocal<S> withInitial(Supper<? extends s> supplier)
// 创建一个线程局部变量,其初始值通过调用给定的提供者生成。
线程安全的集合
阻塞队列
很多线程问题可以使用一个或多个队列以优雅而安全的方式来描述。生成者线程向队列插入元素,消费者线程则获取元素。使用队列,可以安全地从一个线程向另一个线程传递数据。
当试图向队列添加元素而队列已满,或是想从队列移除元素而队列为空的时候,阻塞队列将导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的工具。其他工作线程移除中间结果,并进一步进行修改。队列会自动地平衡负载。如果第一组线程运行比第二组慢,第二组在等待结果时会阻塞。如果第一组线程运行得更快,直到第二组赶上来。
阻塞队列方法分为以下3类,这取决用于当队列满或空时它们完成的动作。如果使用队列作为线程管理工具,将要用到put和take方法。当试图向满队列添加元素或者想从空队列得到队头元素时,add、remove和element操作会抛出异常。当然,在一个多线程程序中,队列可能会在任何时候变空或变满,因此,应当使用offer、poll和peek方法作为替代。如果不能完成任务,这些方法只是给出一个错误而不会抛出异常。
还有带有超时时间的offer方法和poll方法,如,下面的调用 :
boolean success = q.offer(x,100,TimeUnit.MILLSECONDS);
尝试在100毫秒的时间内队尾插入一个元素。如果成功返回true.如果超时,则返回false。
Object head = q.poll(100,TimeUint.MILLSESONDS);
尝试在100毫秒的时间内移除队头元素,如果成功返回队头元素,否则,如果超时,则返回null
如果队列满,则put方法阻塞,如果队列空,则take方法阻塞。它们与不带超时参数的offer和poll方法等效。
默认情况下,LinkedBlockingQueue的容量没有上界,但是,也可以选择指定一个最大容量。LinkedBlockingDeque是一个双端队列。ArrayBlockingQueue在构造时需要指定容量,并且有一个可选的参数指定是否需要公平性。
若设置了公平参数,那么等待了最长时间的线程会优先得到处理。通常,公平性会降低性能,只有在确实非常需要时才使用公平参数。
PriorityBlockingQueue是一个优先队列,而不是先进先出队列。元素按照它们的优先级顺序移除。这个队列没有容量上限,但是,如果队列是空的,获取元素的操作会阻塞。
高效的映射、集和队列
java.util.concurrent包提供了映射、有序集合队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentLinkedQueue.
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分尽可能减少竞争。
与大多数集合不同,这些类的size方法不一定在常量时间内完成操作。确定这些集合的当前大小通常需要遍历。
集合返回弱一致性的迭代器。这意味着迭代器不一定能反映出它们构造之后的所有更改,但是,它们不会将同一个值返回2次,也不会抛出ConcurrentModificationException异常。
并发散列映射可以高效地支持大量阅读器和一定数量的书写器。默认情况下可以有至多16同时运行的书写器线程。当然可以有更多的书写器线程。但是,同一时间如果多于16个,其他线程将暂时阻塞。可以在构造器中指定更大数目,不过,通常都没有这种必要。
ConcurrentLinkQueue<E>()
// 构造一个可以被多线程安全访问的无上限非阻塞的队列。
ConcurrentSkipListSet<E>
ConcurrentSkipListSet<E>(Comparator<? super E> comp)
// 构造一个可以被多线程安全访问的有序集。第一个要求元素实现Comparable接口。
ConcurrentHashMap<K,V>()
ConcurrentHashMap<K,V>(int initialCapacity)
ConcurrentHashMap<K,V>(int initialCapacity,float loadFactor, int concurrencyLevel)
// 构造一个可以被多线程安全访问的散列映射表。默认的初始容量为16.如果每个桶的平均负载超过装载因子。表的大小会重新调整。默认值为0.75。
ConcurrentSkipListMap<K,V>()
ConcurrentSkipListSet<K,V>(Comparator<? super K> comp)
// 构造一个可以被多线程安全访问的有序映像。第一个构造器要求键实现comparable接口。
对并发散列映射的批操作
Java API为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安全地执行。批操作会遍历映射,处理遍历过程中找到的元素。
有3种不同的操作:
- search(搜索)为每个键或值应用一个函数,直到函数生成一个非null的结果。然后搜索终止,返回这个函数的结果。
- reduce(归约)组合所有键或值,这里要使用所提供的一个累加函数。
- forEach为所有键或值应用一个函数。
每个操作都有4个版本:
- operationKeys:处理键
- operationValues:处理值
- operation:处理键和值
- operationEntries:处理Map.Entry对象
对于上述各个操作,需要指定一个参数化阈值。如果映射包含的元素多于这个阈值,就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈值Long.MAX_VALUE.如果希望用尽可能多的线程运行批操作,可以使用阈值1。
如:假设我们希望找出第一个出现次数超过1000次的单词。需要搜索键和值。
String result = Map.search(threshold,(k,v) -> v>1000 ? k:null);
result会设置为第一个匹配的单词,或者搜索函数对所有输入都返回null,则返回null。
forEach方法有两种形式。第一种形式只对各个映射条目应用一个消费者函数,例如:
map.forEach(threshold,
(k,v) -> System.out.println(k + " ->" +v)
);
第二种形式还有一个额外的转换器函数作为参数,要先应用这个函数,结果会传递到消费者。
map.forEach(threshold,
(k,v) -> k + " ->" +v,
System.out::println
);
转换器可以用作一个过滤器。只要转换器返回null,这个值就会被悄无声息地跳过,例如,下面只打印很大的条目。
map.forEach(threshold,
(k,v) -> v>1000 ? k + "->" +v :null, //filter and transformer
System.out::println); // the nulls are not passed to the consumer
任务和线程池
构造一个新的线程开销有些大,因为这涉及与操作系统的交互。如果你的程序中创建了大量的生命期很短的线程,那么不应该把每个任务映射到一个单独的线程,而应该使用线程池。线程池中包含许多准备运行的线程。为线程池提供一个Runnable,就会有一个线程调用run方法。当run方法退出时,这个线程不会死亡,而是留在池中准备为下一个请求提供服务。
Callable与Future
Runnable封装一个异步运行的任务,可以把它想象成一个没有参数和返回值的异步方法。Callable与Runnable类型,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call.
public interface Callable<V>
{
V call() throws Exception;
}
类型参数是返回值的类型。例如,Callable表示一个最终返回Integer对象的异步计算。
Future保存异步计算的结果。可以启动一个计算,将Future对象交给某个线程,然后忘掉它。这个Future对象的所有者在结果计算好之后就可以获得结果。
Future接口有下面的方法:
V get()
V get(long timeout, TimeUnit unit)
void cancel(boolean mayInterrupt)
boolean isCancelled()
boolean isDone()
第一个get方法的调用会阻塞,直到计算完成。第二个get方法也会阻塞,不过如果在计算完成之前调用超时,会抛出一个TimeoutException异常。如果运行该计算的线程被中断,这两个方法都将抛出InterruptedException。如果计算已经完成,那么get方法立即返回。
如果计算还在进行,isDone方法返回false;如果已经完成,则返回true.
可以用cancel方法取消计算,如果计算还没有开始,它会被取消而且不再开始。如果计算正在进行,那么如果mayInterupt参数为true,它就会被中断。
执行Callable的一种方法是使用FutureTask,它实现了Future和Runnable接口,所有可以构造一个线程来运行这个任务:
Callable<Integer> task = ...;
var futureTask = new FutureTask<Integer>(task);
var t = new Thread(futureTask); // it's a Runnable
t.start();
...
Integer result = task.get(); // it's a Future
java.util.concurrent.Callable<V>
v call()
// 运行一个将产生结果的任务
java.util.concurrent.Future<V>
V get()
V get(long time,TimeUnit unit)
// 获取结果,这个方法会阻塞,直到结果可用或者超过了指定的时间。如果不成功,第二个方法会抛出TimeoutException异常。
boolean cancel(boolean mayInterrupt)
// 尝试取消这个任务的运行。如果任务已经开始,并且mayInterrupt参数值为true,它就会被中断。如果成功执行了取消操作,则返回true.
boolean isCancelled()
// 如果任务在完成前被取消,则返回true。
boolean isDone()
// 如果任务结束,无论是正常完成、中途取消,还是发生异常,都返回true。
java.util.concurrent.FutureTask<V>
FutureTask(Callable<V> task)
FutureTask(Runnable task,V result)
// 构造一个既是Future<V> 又是Runnable的对象。
执行器
执行器(Executors)类有许多静态工厂方法,用来构造线程池,下表是对相应的方法进行汇总:
newCachedThreadPool方法构造一个线程池,会立即执行各个任务,如果有空闲线程可用,就使用现有空闲线程执行任务;如果没有可用的空闲线程,则创建一个新线程。
newFixedThreadPool方法构造一个具有固定大小的线程池,如果提交的任务书多于空闲线程数,就把未得到服务的任务放到队列中。当其他任务完成以后在运行这些排队的任务。
newSingleThreadExcetor是一个退化了大小为1的线程池。由一个线程顺序地执行所提交的任务。
如果线程生存期很短,或者大量时间都在阻塞,那么可以使用一个缓存线程池。
可以使用下面的方法之一将Runnable或Callable对象提交给ExecutorService:
Future<T> submit(Callable<T> task)
Future<T> submit(Runnable task)
Future<T> submit(Runnable task, T result)
线程池会在方便的时候尽早执行提交的任务,在调用submit时,会得到一个Future对象,可用来得到结果或者取消任务。
使用完一个线程时,调用shutdown。这个方法启动线程池的关闭序列。被关闭的执行器不在接受新的任务。当所有任务都完成时,线程池中的线程死亡。另一种方法是调用shutdownNow.线程池会取消所有尚为开始的任务。
下面总结了在使用连接池时所做的工作:
- 调用Executors类的静态方法newCachedThreadPool或newFixedThreadPool
- 调用submit提交Runnable或Callable对象
- 保存好返回的Future对象,以便得到结果或者取消任务。
- 当不想在提交任何任务时,调用shutdown.
API
java.util.concurrent.Executors
ExecutorService newCachedThreadPool()
// 返回一个缓存线程池,会在必要的时候创建线程,如果线程已经空闲60秒则终止该线程
ExecutorService newFixedThreadPool(int threads)
// 返回一个线程池,使用给定数目的线程执行任务
ExectorService newSingleThreadExectutor()
// 返回一个执行器,它在一个单独的线程中顺序地执行任务
ScheduleExectorService newScheduledThreadPool(int threads)
// 返回一个线程池,使用给定数目的线程调度任务
ScheduledExecutorService newSingleThreadScheduledExecutor()
// 返回一个执行器,在一个单独的线程中调度任务
java.util.concurrent.ExecutorService
Future<T> submit(Callable<T> task)
Future<T> submit(Runnable task)
Future<T> submit(Runnable task, T result)
// 提交指定的任务来执行
void shutdown()
// 关闭服务,完成已经提交的任务但不在接受新的任务。
fork-join框架
有些应该使用了大量线程,但其中大多数都是空闲的。如,一个web服务器可能会为每个连接分别使用一个线程。另外一些应用可能对处理器内核分别使用一个线程,以完成计算密集型任务。比如图形和视频处理。假设有一个任务处理,它可以很自然地分解为子任务,如下所示:
if(problemSize < threshold)
solve problem directly
else
{
break problem into subproblems
recursively solve each subproblem
combine the results
}
假设想统计一个数组中有多少个元素满足某个特定的属性。可以将这个数组一分为二,分别对这两个部分进行统计,在将结果相加。
要采用框架可用的一种方式完成这种递归计算,需要提供一个扩张RecursiveTask的类或者提供一个扩展RecursiveAction的类。在覆盖compute方法来生成并调用子任务,然后合并其结果。
class Counter extends RecursiceTask<Integer>
{
...
protected Integer compute()
{
if(to - from < THRESHOLD)
{
solve problem directly
}
else
{
int mid = (from + to)/2;
var first = new Counter(values,from,mid,filter);
var second = new Counter(values,mid,to,filter);
invokeAll(first,second);
return first.join() + second.join();
}
}
}
在这里,invokeAll方法接收到很多任务并阻塞,直到所有这些任务全部完成。join方法将生成结果。我们对每个子任务应用join,并返回其结果。
示例代码:
public class CounterTest {
public static void main(String[] args) {
final int SIZE = 10000000;
double[] numbers = new double[SIZE];
for (int i = 0; i < SIZE; i++) {
numbers[i] = Math.random();
}
Counter counter = new Counter(numbers, 0, numbers.length, x -> x > 0.5);
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(counter);
System.out.println(counter.join());
}
}
/**
* 文件描述
*
* @Author: QJS
* @CreateDate: 2021/12/28 14:10
**/
public class Counter extends RecursiveTask<Integer> {
public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;
public Counter(double[] values, int from, int to, DoublePredicate filter) {
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}
/**
* 覆盖生成compute方法并生成子任务
* @return
*/
@Override
protected Integer compute() {
if (to - from < THRESHOLD){
int count = 0;
for (int i = from; i < to; i++) {
if(filter.test(values[i]))
count++;
}
return count;
}else {
int mid = (from + to)/2;
Counter first = new Counter(values, from, mid, filter);
Counter second = new Counter(values, mid, to, filter);
invokeAll(first,second);
return first.join()+second.join();
}
}
}
在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载。这种方法称为工作密取。每个工作线程都有一个双端队列来完成任务。一个工作线程将子任务压入其双端队列的堆头。一个工作线程空闲时,它会从另一个双端队列的队尾“密取”一个任务。由于大的子任务都在队尾,这种密取很少出现。
System.out.println(counter.join());
}
}
/**
-
文件描述
-
@Author: QJS
-
@CreateDate: 2021/12/28 14:10
**/
public class Counter extends RecursiveTask {public static final int THRESHOLD = 1000;
private double[] values;
private int from;
private int to;
private DoublePredicate filter;public Counter(double[] values, int from, int to, DoublePredicate filter) {
this.values = values;
this.from = from;
this.to = to;
this.filter = filter;
}/**
- 覆盖生成compute方法并生成子任务
- @return
*/
@Override
protected Integer compute() {
if (to - from < THRESHOLD){
int count = 0;
for (int i = from; i < to; i++) {
if(filter.test(values[i]))
count++;
}
return count;
}else {
int mid = (from + to)/2;
Counter first = new Counter(values, from, mid, filter);
Counter second = new Counter(values, mid, to, filter);
invokeAll(first,second);
return first.join()+second.join();
}
}
}
在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载。这种方法称为工作密取。每个工作线程都有一个双端队列来完成任务。一个工作线程将子任务压入其双端队列的堆头。一个工作线程空闲时,它会从另一个双端队列的队尾“密取”一个任务。由于大的子任务都在队尾,这种密取很少出现。