Java基础-第12章:并发
- 什么是线程
- 线程状态
- 线程属性
- 同步
- 线程安全的集合
- 任务和线程池
- 异步计算
- 进程
什么是线程
线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
首先来看一个使用了两个线程的简单的程序。这个程序可以在银行账户之间完成资金转账。我们使用一个Bank类,它可以存储给定数目的账户的余额。transfer方法将一定金额从一个账户转移到另外一个账户。
在第一个线程中,我们将钱从账户0转移到账户1.第二个线程将钱从账户2转移到账户3.
下面是在一个单独的线程中运行一个任务的简单过程。
- 将执行这个任务的代码放在一个类的run方法中,这个类要实现Runnable接口,Runnable接口非常简单,只有一个方法:
public interface Runnable
{
void run();
}
由于Runnable是一个函数式接口,可以用一个lambda表达式创建一个实例:
Runnable r = () -> {task code};
- 从这个Runnable构造一个Thread对象:
var t = new Thread(r);
- 启动线程:
t.start();
为了建立单独的线程来完成转账,我们只需要把转账代码放在一个Runnable的run方法中,然后启动一个线程:
Runnable r = () ->{
try
{
for(int i = 0;i < STEPS;i++)
{
double amount = MAX_AMOUNT * Math.random();
bank.transfer(0,1,amount);
Thread.sleep((int)(DELAY*Math.random()));
}
}
};
var t = new Thread(r);
t.start();
对于给定的步骤数,这个线程会转账一个随机金额,然后休眠一个随机时间
我们要捕获sleep方法有可能抛出的InterruptedException异常。这个异常等下会讨论,一般说来,中断用来请求终止一个线程,相应地,出现InterruptedException时,run方法会退出。
package threads;
/*
建立两个线程,一个线程将账户0的钱转账到账户1,另一个线程将账户2的钱转账到账户3
打印线程,会发现两个线程交替运行,这说明他们是在并发运行
*/
public class ThreadTest
{
public static final int DELAY = 10;
public static final int STEP = 100;
public static final double MAX_AMOUNT = 1000;
public static void main(String[] args)
{
var bank = new Bank(4,100000);
Runnable task1 = () ->
{
try
{
for (int i = 0; i < STEP;i++)
{
bank.transfer(0,1,MAX_AMOUNT*Math.random());
Thread.sleep((int)(DELAY*Math.random()));
}
}
catch (InterruptedException e)
{
}
};
Runnable task2 = () ->
{
try
{
for (int i = 0; i < STEP; i++)
{
bank.transfer(2,3,MAX_AMOUNT*Math.random());
Thread.sleep((int)(DELAY*Math.random()));
}
}
catch (InterruptedException e)
{
}
};
new Thread(task1).start();
new Thread(task2).start();
}
}
打印结果:
可以看到线程1和线程2输出是交错的,这说明他们是在并发运行。
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(终止)
新建线程
当用new操作符创建一个新线程时,如new Thread®,这个线程还没有开始运行。这意味着它的状态是新建,当一个线程处于新建状态时,程序还没有开始运行线程中的代码。在线程运行之前还有一些基础工作要做。
可运行线程
一旦调用start方法,线程就处于可运行的状态。一个可运行的线程可能正在运行也可能没有运行。要由操作系统为线程提供具体的运行时间。(不过,Java规范没有将正在运行作为一个单独的状态。一个正在运行的线程仍处于可运行的状态)
一旦一个线程开始运行,它不一定始终保持运行。 事实上,运行中的线程有时需要暂停,让其它线程有机会运行。线程调度的细节依赖于操作系统提供的服务。抢占式调度系给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程一个机会来运行。当选择下一个线程时,操作系统会考虑线程的优先级。
记住,在任何给定的时刻,一个可运行的线程可能正在运行也可能没有运行(正是因为这样,这个状态叫做“可运行”而不是“运行”)。
Java.lang.Thread
方法 | 描述 |
---|---|
static void yield() | 使当前正在运行的线程向另一个线程交出运行权。注意这是一个静态方法 |
阻塞和等待线程
当线程处于阻塞或等待状态时,它暂时是不活动的,它不运行任何代码,而且消耗最少的资源,要由线程调度器重新激活这个线程。具体细节取决于它是怎样到达非活动状态的。
- 当一个线程试图获取一个内部的对象锁(而不是java.util.concurrent库中的Lock),而这个锁目前被其他线程占有,该线程就会被堵塞。当所有其他线程释放了这个锁,并且线程调度器允许该线程持有这个锁时,它将变成非阻塞状态。
- 当线程等待另一个线程通知调度器出现一个条件时,这个线程会进入一个等待状态。调用Object.wait或Thread.join方法,或者是等待java.util.concurrent库中的Lock或Condition时,就会出现这种情况。实际上,等待状态和阻塞状态没什么太大区别。
- 有几个方法有超时参数,调用这些方法会让线程进入计时等待状态。这一状态会一直保持到超时期满或者接到适当的通知。带有超时参数的方法有Thread.sleep和计时版的Object.wait,Thread.join,Lock.tryLock,Condition.await。
终止线程
线程会由于以下两个原因之一而终止
- run方法自然退出,线程自然终止
- 因为一个没有捕获的异常终止了run方法,使线程意外终止
(其实还可以调用stop方法杀死一个进程。该方法抛出一个ThreadDeath错误对象,这会杀死线程,不过该方法已弃用,不要使用stop方法)
java.lang.Thread
方法 | 描述 |
---|---|
void join() | 等待终止指定的线程 |
void join(long millis) | 等待指定的线程终止或者等待经过指定的毫秒数 |
Thread.State getState() | 得到这个线程的状态;取值为NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING或TERMINATED |
void stop() | 停止该线程。该方法已经废弃。 |
void suspend() | 暂停这个线程的执行。这个方法已经废弃。 |
void resume() | 恢复线程。这个方法只能在调用suspend()之后使用。这个方法已经废弃。 |
线程属性
下面将讨论线程的各种属性,包括中断的状态,守护线程,未捕获异常的处理器以及不应使用的一些遗留特性。
中断线程
当线程的run方法执行方法体中最后一条语句再执行return语句返回时,或者出现了方法没有捕获的异常时,线程将终止。
除了已经废弃的stop方法,没有办法可以强制线程终止,不过,interrupt方法可以用来请求终止一个线程。
当对一个线程调用interrupt方法时,就会设置线程的中断状态。这是每个线程都有的boolean标志。每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想得出是否设置了中断状态,首先调用静态的Thread.currentThread方法获得当前线程,然后调用isInterrupted方法
while(!Thread.currentThread().isInterrupted() && ...)
{
...
}
但是,如果线程被阻塞,就无法检查中断状态。这里就要引入InterruptedException异常。当在一个被sleep或wait调用阻塞的线程上调用interrupt方法时,那个阻塞调用(即sleep或wait调用)将被一个Interrupted异常中断。
没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应该处理这个异常,然后再继续执行。但是,更普遍的情况是,线程只希望将中断解释为一个终止请求,这种线程的run方法具有如下形式。
Runnable r = ()->
{
try
{
while(!Thread.currentThread().isInterrupted() && ...)
{
...
}
}
catch(InterruptedException e)
{
//线程被中断在sleep或者wait中
}
finally
{
//如果有需要,就进行内存回收
}
//退出run方法同时终止了线程
};
如果在每次工作迭代之后都调用了sleep方法(或者其他可中断方法),isInterrupted检查既没有必要也没有用处。如果设置了中断状态,此时倘若调用sleep方法,它不会休眠。实际上,它会清除中断状态(!)并抛出InterruptedException。因此,如果你的循环调用了sleep,不要检测中断状态,而应当捕获InterruptedException,如下
Runnable r = () ->
{
try
{
whiel(...)
{
...
Thread.sleep(Delay);
}
}
catch(InterruptedException e)
{
//线程已被中断在sleep过程中
}
finally
{
//如果有需要,就进行内存回收
}
//退出run方法同时终止了线程
}
(有两个很相似的方法 interrupted和isInterrupted。
前者是一个静态方法,检查当前线程是否被中断。而且,调用后会清除该线程的中断状态。
而后者是一个实例方法,可以用来检查是否有线程被中断,调用这个方法不会改变中断状态。)
你可能发现很多发布的代码在底层抑制了InterruptedException异常,例如:
void mySubTask()
{
try
{
sleep(delay);
}
catch(InterruptedException e){}
}
不要这样做!如果实在不知道catch能做什么有意义的工作,仍然有两种合理的选择:
- 在catch语句中调用Thread.currentThread().interrupt()来设置中断状态。这样一来调用者就可以检测中断状态。
void mySubTask()
{
try()
{
sleep(delay);
}
catch(InterruptedException e)
{
Thread.currentThread.interrupt();
}
}
- 或者更好的选择是,用throws InterruptedException标记你的方法,去掉try语句块。这样一来调用者(或者最终的run方法)就可以捕获这个异常
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.setDameon(true);
将一个线程转换为守护线程。守护线程的唯一用途是为其他线程提供服务。当只剩下守护线程时,虚拟机就会退出。因为若只剩下守护线程,就没必要运行守护线程。
java.lang.Thread
void setDameon(boolean isDaemon)
标识该线程为守护线程或用户线程。这一方法必须在线程启动之前调用。
未捕获的异常
线程的run方法不能抛出任何检查型异常,但是,非检查型异常可能会导致线程终止。在这种情况下,线程会死亡。
不过,对于可以传播的异常,并没有任何catch子句。实际上,在线程死亡之前,异常会传递到一个用于处理未捕获异常的处理器。
这个处理器必须属于一个实现了Thread.UncaughtExceptionHandle接口的类。这个接口有一个方法。
void uncaughtException(Thread t,Throwable e)
可以用setUncaughtExceptionHandler方法为任何一个线程安装一个处理器,也可以用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有线程安装一个默认的处理器。替代处理器可以使用日志API将未捕获异常的报告发送到一个日志文件。
如果没有安装默认处理器,默认处理器为null,但是,如果没有为单个线程安装处理器。那么处理器就是该线程的ThreadGroup对象。
ThreadGroup类实现了Thread.UncaughtExceptionHandler接口。它的uncaughtException方法执行以下操作:
- 如果该线程组有父线程组,那么调用父线程组的uncaughtException方法
- 否则,如果Thread.getDefaultExceptionHandler方法返回了一个非null的处理器,则调用该处理器
- 否则,如果Throwable e是ThreadDeath的一个实例,什么都不做
- 否则,将线程的名字及Throwable e的栈轨迹输出到System.err
你在程序中肯定看到过许多这样的栈轨迹
java.lang.Thread
方法 | 描述 |
---|---|
static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler) | 设置未捕获异常的处理器 |
static void Thread.UncaughtExceptionHandler getDefaultUncaughtExceptionHandler() | 获取未捕获异常的处理器 |
void setUncaughtExceptionHandle(Thread.UncaughtExceptionHandler handler) | 设置未捕获异常的处理器 |
static void Thread.UncaughtExceptionHandler getUncaughtExceptionHandler() | 获取未捕获异常的处理器。如果没有安装处理器,将线程组对象作为处理器 |
java.lang.Thread.UncaughtExceptionHandler
方法 | 描述 |
---|---|
void uncaughtException(Thread t ,Throwable e) | 当线程因一个未捕获异常而终止时,要记录一个定制报告 |
java.lang.ThreadGroup
方法 | 描述 |
---|---|
void uncaughtException(Thread t ,Throwable e) | 如果有父线程组,调用父线程组的这个方法,否则,如果Thread t有默认的处理器,就调用默认的处理器,否则将栈轨迹打印到错误输出流(不过,如果e是一个ThreadDeath对象,则会抑制栈轨迹,ThreadDeath对象由已经废弃的stop方法产生) |
同步
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么呢?可以想见,这两个线程会相互覆盖。取决于线程访问的数据的次序,可能会导致对象破坏。这种情况称为竞态条件。
竞态条件的一个例子
为了避免多线程破坏共享数据,必须学习如何同步存取。现在你将看到如果没有使用同步会发生什么。
下面的代码,我们还是选择模拟我们的银行账户存取。与前面不同,我们会随机地选择从哪个源账户转账到目标账户。
Chapter12.Bank.java
package Chapter12;
import java.util.Arrays;
import java.util.concurrent.locks.ReentrantLock;
public class Bank
{
private final double[] accounts;
// private ReentrantLock banklock = new ReentrantLock();
public Bank(int n,double initialBalance)
{
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
}
public void transfer(int from,int to,double amount)
//将第from个的账户的钱转到第to个账户里面
{
// banklock.lock();
// try
// {
if (accounts[from] < amount) {return;}
System.out.print(Thread.currentThread());
accounts[from]-=amount;
System.out.printf("%10.2f from %d to %d",amount,from,to);
accounts[to]+=amount;
System.out.printf("Total Balance:%10.2f%n",getTotalBalance());
// }
// finally
// {
// banklock.unlock();
// }
}
public double getTotalBalance()
{
double sum = 0;
for (double temp:accounts)
{
sum+=temp;
}
return sum;
}
public int size()
{
return accounts.length;
}
}
Chapter12.UnsynchBankTest.java
package Chapter12;
public class UnsynchBankTest
{
public static final int NACCOUNTS = 100;
//银行总账户数
public static final double INITIAL_BALANCE = 1000;
//每个账户的初始金额
public static final double MAX_AMOUNT = INITIAL_BALANCE;
//转账的最大金额
public static final int DELAY = 10;
//线程休眠的最长时间ms
public static void main(String[] args)
{
var 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((int)(DELAY * Math.random()));
}
}
catch (InterruptedException e) {}
};
var t =new Thread(r);
t.start();
}
}
}
运行结果:
对于最初的几次交易,银行总金额能维持在100000元,但是,经过一段时间后,总额却发生了变化,没有人会将钱存到如此不安全的银行。现在,不使用同步的后果你已经看到了。下面,我们会对其分析并如何避免上述情况的发生。
现在,我们假设有两个线程同时执行指令
accounts[to] +=amount
这个指令做如下处理:
- 将accounts[to]加载到寄存器
- 增加amount
- 将结果写回accounts[to]
现在,假定第一个线程执行步骤1和2(在线程1寄存器中),然后它的运行权被抢占,再假设第二个线程被唤醒,更新account数组的同一个元素。然后,第1个线程被唤醒并完成其第3步。
这个动作会抹去第2个线程做出的更新,这样一来,总金额就不正确了。
我们的测试程序可以检测到这种破坏,在一个多内核的处理器上,出问题的风险相当高。真正的问题是transfer方法可能会在执行到中间时被中断。如果能够确保线程失去控制之前方法已经运行完成,那么银行账户对象的状态就不会被破坏。
锁对象
有两种机制可防止并发访问代码块。
- 可以使用synchronized关键字,它会自动提供一个锁以及相关的条件,对于大多数需要显式锁的情况,这种机制功能很强大,也很便利
- 另外可以使用ReentrantLock(重入锁)类,本节先介绍这个让你更容易地理解synchronized关键字。
java.util.concurrent框架为这些基础机制提供了单独的类
用ReentrantLock保护代码块的基本结构如下:
mylock.lock();
try
{
//代码块
...
}
finally
{
myLock.unlock();//保证锁打开即使异常抛出
}
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会暂停,直到第一个线程释放这个锁对象。
注意:
- 要把unlock操作放在finally子句中,这一点至关重要。如果在临界区的代码抛出一个异常,锁必须释放。否则,其他线程永远阻塞。
- 使用锁时,就不能使用try-with-resources语句,首先,解锁方法名不是close。不过,即使将它重命名,try-with-resources也无法正常工作。它的首部希望声明一个新变量。但是如果使用一个锁,你可能想使用多个线程共享的那个变量(而不是新变量)。
下面使用一个锁来保护Bank类的transfer方法
public class Bank
{
private final double[] accounts;
private ReentrantLock banklock = new ReentrantLock();
...
public void transfer(int from,int to,double amount)
//将第from个的账户的钱转到第to个账户里面
{
banklock.lock();
try
{
if (accounts[from] < amount) {return;}
System.out.print(Thread.currentThread());
accounts[from]-=amount;
System.out.printf("%10.2f from %d to %d",amount,from,to);
accounts[to]+=amount;
System.out.printf("Total Balance:%10.2f%n",getTotalBalance());
}
finally
{
banklock.unlock();
}
}
}
假设一个线程调用了transfer,但是在执行结束前被抢占。再假设第二个线程也调用了transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它会暂停,必须等第一个线程执行完transfer方法。当第一个线程释放锁时,第二个线程才能开始运行。
加了加锁的代码后,再次运行刚刚的程序。这个程序能一直运行下去,且不会发生银行余额错误。
注意每个Bank对象都有自己的ReentrantLock对象,如果两个线程试图访问同一个Bank对象,那么锁可以保证串行化访问。不过,如果两个线程访问不同的Bank对象,每个线程会得到不同的锁对象,两个线程都不会阻塞。本该如此,因为线程在操纵不同的Bank实例时,线程之间不会发生任何相互的影响。
这个锁称为重入锁(reentrant)锁,因为线程可以反复获得已拥有的锁。锁有一个持有计数来跟踪对lock方法的嵌套调用。线程每一次调用lock后都要调用unlock来释放锁。由于这个特性,被一个锁保护的代码调用另一个使用相同锁的方法。
例如,transfer方法调用getToBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出时,持有计数变回1.当transfer方法退出的时候,持有计数变为0,线程释放锁。
java.util.concurrent.locks.Lock
方法 | 描述 |
---|---|
void lock() | 获得这个锁,如果锁当前被另一个线程占有,则堵塞 |
void unlock() | 释放这个锁 |
java.util.concurrent.locks.ReentrantLock
方法 | 描述 |
---|---|
ReentrantLock() | 构造一个重入锁,可以用来保护临界区 |
ReentrantLock(boolean fair) | 构造一个采用公平策略的锁。一个公平锁倾向于等待时间最长的线程。不过,这种公平保证可能会严重影响性能。所以,默认情况下,不要求锁是公平的。 |
条件对象
通常,线程进入临界区却发现只有满足了某个条件之后它才会执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。
Chapter12.Bank.java
package Chapter12;
import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Bank
{
private final double[] accounts;
private ReentrantLock banklock = new ReentrantLock();
private Condition sufficefunds;
//条件对象
public Bank(int n,double initialBalance)
{
accounts = new double[n];
Arrays.fill(accounts,initialBalance);
sufficefunds = banklock.newCondition();
//使用newCondition获取一个条件对象
}
public void transfer(int from,int to,double amount)
//将第from个的账户的钱转到第to个账户里面
{
banklock.lock();
try
{
while (accounts[from] < amount)
{
sufficefunds.await();
//如果资金不足,调用awiait()方法,当前线程暂停,并放弃锁
}
System.out.print(Thread.currentThread());
accounts[from]-=amount;
System.out.printf("%10.2f from %d to %d",amount,from,to);
accounts[to]+=amount;
System.out.printf("Total Balance:%10.2f%n",getTotalBalance());
sufficefunds.signalAll();
//signalAll()重新激活等待这个条件的所有线程,当这些线程从等待集中出来时,
// 它们再次成为可运行的线程,并去竞争锁,一旦锁可用,它们中的某个线程将从await()调用返回,得到这个锁,并从之前暂停的地方继续执行
//此时线程应当再次测试条件。不能保证一定满足条件,signalAll方法仅仅是通知等待的线程
}
catch (InterruptedException e)
{
}
finally
{
banklock.unlock();
}
}
public double getTotalBalance()
{
double sum = 0;
for (double temp:accounts)
{
sum+=temp;
}
return sum;
}
public int size(){
return accounts.length;
}
}
一个锁对象有一个或多个相关联的条件对象。你可以用newCondition方法获得一个条件对象。习惯上会给每个条件对象一个合适的名字来反映它表示的条件,例如,在这里我们建立了一个条件对象来表示“资金充足”条件。
最终需要有某个其他线程调用signalAll方法,这一点至关重要。当一个线程调用await时,它就没有办法重新自行激活。它只能寄希望于其他线程。如果没有其他线程来重新激活,它就永远不再运行。也就是死锁。(deadlock)
应该什么时候调用signalAll呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll。例如,当一个账户余额发生变化时,就应该给一个等待的线程一个机会来检查余额。
注意signalAll调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁之后竞争对象。
另一个signal方法只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。这个方法更高效,但也存在危险。如果随机选择的线程发现自己还不能运行,它会再次阻塞。如果没有其它线程再次调用signal方法,系统会进入死锁。
java.util.concurrent.locks.Lock
方法 | 描述 |
---|---|
Condition newcondition() | 返回一个与这个锁相关联的条件对象 |
java.util.concurrent.locks.Condition
方法 | 描述 |
---|---|
void awiait() | 将该线程放到等待集中 |
void signal() | 解除该条件集中所有线程的阻塞状态 |
void signal()从该条件的等待集中随机选择一个线程,并解除其阻塞状态 |
synchronized关键字
在前面,介绍了如何使用Lock和Condition对象。在进入深入之前,我们先总结一下锁和条件的关键之处。
- 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码
- 锁可以管理试图进入保护代码段的线程。
- 锁可以拥有一个或多个相关的条件对象
- 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程 。
话句话说
public synchronized void method()
{
method body
}
等价于
public void method()
{
this.intrinsicLock.lock() ;
try
{
method body
}
finally
{
this.intrinsicLock.unlock() ;
}
}
可以简单地声明Bank类的transfer方法为synchronzied,而不是使用一个显式锁。
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法删除等待线程的阻塞状态。等价于前面的wait/signal和signalAll。
将静态方法声明为 synchronized 也是合法的。如果调用这种方法 ,该方法获得相关的类对象的内部锁。例如 ,如果 Bank 类有一个静态同步的方法 ,那么当该方法被调用时 ,Bank.lass对象的锁被锁住。因此 ,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法 。
Bank类的transfer方法代码如下:
public synchronized void transfer(int from,int to,double amount)
//将第from个的账户的钱转到第to个账户里面
{
try
{
while (accounts[from] < amount)
{
wait();
//如果资金不足,调用wait()方法,当前线程暂停,并放弃锁
}
System.out.print(Thread.currentThread());
accounts[from]-=amount;
System.out.printf("%10.2f from %d to %d",amount,from,to);
accounts[to]+=amount;
System.out.printf("Total Balance:%10.2f%n",getTotalBalance());
notifyAll();
//notifyAll()重新激活等待这个条件的所有线程,当这些线程从等待集中出来时,
// 它们再次成为可运行的线程,并去竞争锁,一旦锁可用,它们中的某个线程将从wait()调用返回,得到这个锁,并从之前暂停的地方继续执行
//此时线程应当再次测试条件。不能保证一定满足条件,notifyAll方法仅仅是通知等待的线程
}
catch (InterruptedException e)
{
}
}
内部锁和条件有一定限制:
- 不能中断一个正在试图获得锁的线程 。
- 试图获得锁时不能设定超时 。
- 每个锁仅有单一的条件 , 可能是不够的
在代码里应该使用哪一种?给出以下建议:
- 最好既不使用 Lock / Condition 也不使用 synchronized 关键字。在许多情况下你可以使用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁 。例如 , 在后面你会看到如何使用阻塞队列来同步完成一个共同任务的线程。
- 如果synchronized关键字适合你的程序,那么请尽量使用它,它可以减少代码的数量,减少出错的概率。
- 如果特别需要Lock/Condition结构提供的独有特性时,才使用它。
java.lang.Object
方法 | 描述 |
---|---|
void notifyAll() | 解除那些调用wait()方法的线程的阻塞的状态,该方法只能在同步方法或同步块里面调用。如果当前线程不是对象锁的拥有者,该方法会抛出一个 IllegalMonitorStateException异常 |
void notify() | 随机选择一个在该对象上调用wai()的线程,解除其阻塞状态。该方法只能在一个同步方法或者同步块中使用。如果当前线程不是对象锁的拥有者,该方法会抛出一个 IllegalMonitorStateException异常 |
void wait() | 导致线程进入等待状态直到它被通知,该方法只能一个同步方法中调用。如果当前线程不是对象锁的拥有者,该方法会抛出一个 IllegalMonitorStateException异常 |
void wait(long mills) | |
void wait(long mills,int nanos) | 导致线程进入等待状态直到它被通知或者经过指定的时间,该方法只能一个同步方法中调用。如果当前线程不是对象锁的拥有者,该方法会抛出一个 IllegalMonitorStateException异常。参数:mills 毫秒数。nanos 纳秒数,<1000000 |
volatile域
有时,仅仅为了一个或两个实例域就使用同步,显得开销很大。然而,如果不采取措施来对实例域进行处理,使用现代的处理器和编译器,出错的可能性很大。
volatile关键字为实例域的同步访问提供了一种免锁机制,如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
注意,volatile变量不能提供原子性。
final变量
上面可知,除了使用锁或者volatile修饰符。否则无法从多个线程安全地读取一个域。
还有一种情况,可以安全地访问一个共享域,即这个域声明为final时,考虑以下声明:
final Map<String,Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造才看到这个account变量。
如果不使用final,就不能保证其他线程看到的是account更新后的值。它们可能看到的都只是null。而不是新构造的HashMap。
当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要同步。
线程局部变量
前面我们讨论了线程间共享变量的风险。有时可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例。例如,SimpleDateFormat类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
结果可能会很混乱,因为dateFormat使用的内部数据结构可能会被并发的访问所破坏。当然也可以使用同步,但开销很大。或者也可以在需要时构造一个局部的SimpleDateFormat对象,不过没必要。要为每个线程构造一个实例,可以使用以下代码:
public static final Threadlocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用 :
String dateStamp = dateFormat.get().format(new Date());
在一个给定线程中首次调用 get 时,会调用 initialValue 方法 。 在此之后 , get 方法会返回属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题,java.util.Random类是线程安全的,但是如果多个线程需要等待一个共享的随机数生成器,这会很低效。
可以使用ThreadLocal辅助类为各个线程提供一个单独的生成器,不过JavaSE7还提供了一个便利类。如下:
int random = ThreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()调用会返回于当前线程特定的Random类实例
java.lang.ThreadLocal
方法 | 描述 |
---|---|
T get() | 得到这个线程的当前值。如果是首次调用get,会调用initialize来得到这个值。 |
protected initialize() | 应覆盖这个方法来提供一个初始值。默认情况下,这个方法返回null。 |
void set(T t) | 为这个线程设置一个新值 |
void remove() | 删除对应线程的值 |
static < S> ThreadLocal< S> withInitial(Supplier <? extends S> supplier) | 创建一个线程局部变量,其初始值通过给定的supplier生成 |
java.util.concurrent.ThreadLocalRandom
方法 | 描述 |
---|---|
static ThreadLocalRandom current() | 返回于当前线程的Random类实例 |
为什么弃用stop方法和suspend方法
初始的Java版本定义了一个stop方法用来终止一个线程,以及一个suspend方法用来阻塞一个线程直至另一个线程调用resume。stop和suspend方法有一些共同点,都试图控制一个给定线程的行为。这三个方法现在已经弃用。
首先来看看 stop 方法,该方法终止所有未结束的方法 , 包括 run 方法 。 当线程被终止 , 立即释放被它锁住的所有对象的锁。 这会导致对象处于不一致的状态 。 例如 ’假定 TransferThread在从一个账户向另一个账户转账的过程中被终止, 钱款已经转出 , 却没有转人目标账户 , 现在银行对象就被破坏了。 因为锁已经被释放 , 这种破坏会被其他尚未停止的线程观察到 。
当线程要终止另一个线程时 , 无法知道什么时候调用 stop 方法是安全的 , 什么时候导致对象被破坏。因此 , 该方法被弃用了 。 在希望停止线程的时候应该中断线程 ,被中断的线程会在安全的时候停止。
接下来 , 看看 suspend 方法有什么问题。 与 stop 不同 , suspend 不会破坏对象 。 但是 ,如果用 suspend 挂起一个持有一个锁的线程 , 那么, 该锁在恢复之前是不可用的 。 如果调用suspend 方法的线程试图获得同一个锁 , 那么程序死锁 : 被挂起的线程等着被恢复 , 而将其挂起的线程等待获得锁。
阻塞队列
Callable与Future
Runnable封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方法。Callable与Runnable类似,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call。
public interface Callable<V>
{
V call() throws Exception;
}
类型参数是返回值的类型。例如Callable表示一个最终返回Integer对象的异步计算。
Future保存异步计算的结果。可以启动一个计算,将Future对象交给某个线程,然后忘掉它。Future对象的所有者在结果计算好之后就可以获得它。
Future接口具有以下方法:
public interface Future<V>
{
V get() throws ...;
V get(long timeout,Time unit) throws ...;
void cancel(boolean mayInterrupt);
boolean isCancelled();
boolean isDone();
}
第一个get方法的调用阻塞,直到计算完成。如果在计算完成之前,第二个方法的调用超时,抛出一个TimeoutException异常。如果运行该计算的线程被中断,两个方法都将抛出InterruptedException。如果计算已经完成,那么get方法立即返回。
如果计算还在进行,isDone方法返回false;如果完成了返回true。
可以用 cancel 方法取消该计算。 如果计算还没有开始 , 它被取消且不再开始 。 如果计算处于运行之中, 那么如果 maylnterrupt 参数为 true ,它就被中断。
FutureTask包装器是一种非常便利的机制,可将Callable转换成Future和Runnable,它同时实现二者接口。例如:
Callable<Integer> myComputation = ...;
FutureTask<Integer> task = new FutureTask<Integer>(myComputation);
Thread t = new Thread(task);//Runnable类型
t.start();
...
Integer result = task.get();//Future类型
java.util.concurrent.Callable
方法 | 描述 |
---|---|
V call() | 运行一个将产生结果的任务 |
java.util.concurrent.Future
方法 | 描述 |
---|---|
V get() | |
V get(long time,TimeUnit unit) | 获取结果,如果没有结果可用,则堵塞直到真正得到结果超过指定的时间为止。如果不成功,第二个方法会抛出TimeoutException异常。 |
boolean cancel(boolean mayInterrupted) | 尝试取消这一任务的运行。如果任务已经开始,并且mayInterrupted参数值为true,它就会被中断。如果成功执行了取消操作,返回true。 |
boolean isCancelled() | 如果任务在完成前被取消了,则返回true |
boolean isDone() | 如果任务结束,无 |