使用的教材是java核心技术卷1,我将跟着这本书的章节同时配合视频资源来进行学习基础java知识。
前面介绍了如何使用Lock和Condition对象。在进一步深人之前,总结一下有关锁和条件的关键之处:
•锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
•锁可以管理试图进入被保护代码段的线程。
•锁可以拥有一个或多个相关的条件对象。
•每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock和Condition接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下,并不需要那样的控制,并且可以使用一种嵌人到Java语言内部的机制。从1.0版开始,Java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,
public synchronized void method()
{
method body
}
等价于
public void method()
{
this.intrinsicLock.lock();
try
{
method body
}
finally
{
this.intrinsicLock.unlock();
}
}
例如,可以简单地声明Bank类的transfer方法为synchronized,而不是使用一个显式的锁。
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAU/notify方法解除等待线程的阻塞状态。换句话说,调用wait或notityAll等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
wait、notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为await、signalAll和signal以便它们不会与那些方法发生冲突。
例如,可以用 Java实现 Bank 类如下:
class Bank
{
private double[] accounts;
public synchronized void transfer(int from,int to, int amount) throws InterruptedException
{
while (accounts[from]
可以看到,使用synchronized关键字来编写代码要简洁得多。当然,要理解这一代码,你必须了解每一个对象有一个内部锁,并且该锁有一个内部条件。由锁来管理那些试图进入synchronized方法的线程,由条件来管理那些调用wait的线程。
将静态方法声明为synchronized也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果Bank类有一个静态同步的方法,那么当该方法被调用时,Bankxlass对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限。包括:
•不能中断一个正在试图获得锁的线程。
•试图获得锁时不能设定超时。
•每个锁仅有单一的条件,可能是不够的。
在代码中应该使用哪一种?Lock和Condition对象还是同步方法?下面是一些建议:
•最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下你可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁。
•如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
下面的程序给出了用同步方法实现的银行实例。
•如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition。
/**
*@author zzehao
*/
import java.util.*;
//A bank with a number of bank accounts that uses locks for serializing access.
public class Bank
{
private final double[] accounts;
//Constructs the bank.
public Bank(int n, double initialBalance)
{
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
//Transfers money from one account to another.
public synchronized void transfer(int from, int to, double amount) throws InterruptedException
{
while(accounts[from] < amount)
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();
}
//Gets the sun of all account balances.
public synchronized double getTotalBalance()
{
double sum = 0;
for (double a : accounts)
sum += a;
return sum;
}
//Gets the number of accounts in the bank.
public int size()
{
return accounts.length;
}
}
正如刚刚讨论的,每一个Java对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:
synchronized (obj)//this is the syntax for a synchronized block
{
critical section
}
于是它获得obj的锁。有时会发现“特殊的”锁,例如:
public class Bank
{
private doublet[] accounts;
private Object lock = new Object();
...
public void transfer(int from, int to, int amount)
{
synchronized (lock)//an ad-hoc lock
{
accounts[from] -=amount;
accounts[to]+=amount;
}
System.out.println(...);
}
}
在此,lock对象被创建仅仅是用来使用每个Java对象持有的锁。有时程序员使用一个对象的锁来实现额外的原子操作,实际上称为客户端锁定(clientsidelocking)0例如,考虑Vector类,一个列表,它的方法是同步的。现在,假定在Vector
public void transfer (Vector accounts, int from, int to, int amount)// Error
{
accounts.set(from, accounts.get(from)- amount) ;
accounts.set(to, accounts.get(to) + amount) ;
System. out.println(.. .) ;
}
Vector类的get和set方法是同步的,但是,这对于我们并没有什么帮助。在第一次对get的调用已经完成之后,一个线程完全可能在transfer方法中被剥夺运行权。于是,另一个线程可能在相同的存储位置存人不同的值。但是,我们可以截获这个锁:
public void transfer (Vector accounts, int from, int to, int amount)
{
synchronized (accounts)
{
accounts.setCfron, accounts.get(from)- amount):
accounts.set(to, accounts.get(to) + amount) ;
}
System.out.println(... );
}
这个方法可以工作,但是它完全依赖于这样一个事实,Vector类对自己的所有可修改方法都使用内部锁。然而,这是真的吗?Vector类的文档没有给出这样的承诺。不得不仔细研究源代码并希望将来的版本能介绍非同步的可修改方法。如你所见,客户端锁定是非常脆弱的,通常不推荐使用。
锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器(monitor),这一概念最早是由PerBrinchHansen和TonyHoare在20世纪70年代提出的。用Java的术语来讲,监视器具有如下特性:
•监视器是只包含私有域的类。
•每个监视器类的对象有一个相关的锁。
•使用该锁对所有的方法进行加锁。换句话说,如果客户端调用obj.meth0d(),那么obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。
•该锁可以有任意多个相关条件。
监视器的早期版本只有单一的条件,使用一种很优雅的句法。可以简单地调用await accounts[from]>=balance而不使用任何显式的条件变量。然而,研究表明盲目地重新测试条件是低效的。显式的条件变量解决了这一问题。每一个条件变量管理一个独立的线程集。
Java设计者以不是很精确的方式采用了监视器概念,Java中的每一个对象有一个内部的锁和内部的条件。如果一个方法用synchronized关键字声明,那么,它表现的就像是一个监视器方法。通过调用wait/notifyAU/notify来访问条件变量。
然而,在下述的3个方面Java对象不同于监视器,从而使得线程的安全性下降:
•域不要求必须是private。
•方法不要求必须是synchronized。
•内部锁对客户是可用的
。这种对安全性的轻视激怒了Per BrinchHansen。他在一次对原始Java中的多线程的严厉评论中,写道:“这实在是令我震惊,在监视器和并发Pascal出现四分之一个世纪后,Java的这种不安全的并行机制被编程社区接受。这没有任何益处。”
有时,仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。毕竟,什么地方能出错呢?遗憾的是,使用现代的处理器与编译器,出错的可能性很大。
•多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
•编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变!
如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序指令。
volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
例如,假定一个对象有一个布尔标记done,它的值被一个线程设置却被另一个线程査询,如同我们讨论过的那样,你可以使用锁:
private boolean done;
public synchronized boolean isDone(){ return done; }
public synchronized voidsetDone() { done = true; }
或许使用内部锁不是个好主意。如果另一个线程已经对该对象加锁,isDone和setDone方法可能阻塞。如果注意到这个方面,一个线程可以为这一变量使用独立的Lock。但是,这也会带来许多麻烦。
在这种情况下,将域声明为volatile是合理的:
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
Volatile 变量不能提供原子性。例如,方法
public void filpDone() {done - !done;}//not atomic
不能确保翻转域中的值。不能保证读取、翻转和写入不被中断。
上一节已经了解到,除非使用锁或volatile修饰符,否则无法从多个线程安全地读取一个域。
还有一种情况可以安全地访问一个共享域,即这个域声明为final时。考虑以下声明:
final Map accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才看到这个accounts变量。
如果不使用final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到null,而不是新构造的HashMap。
当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。
假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为volatile。
java.util.concurrent.atomic包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如,Atomiclnteger类提供了方法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(lagest.get(),observed));//Error==race condition!
这个更新不是原子的。实际上,应当在一个循环中计算新值和使用 compareAndSet:
do {
oldValue=largest.get();
newValue=Math.max(oldValue, observed);
} while ( !largest.compareAndSet(oldValue, newValue));
如果另一个线程也在更新largest,就可能阻止这个线程更新。这样一来,compareAndSet会返回false,而不会设置新值。在这种情况下,循环会更次尝试,读取更新后的值,并尝试修改。最终,它会成功地用新值替换原来的值。这听上去有些麻烦,不过compareAndSet方法会映射到一个处理器操作,比使用锁速度更快。在JavaSE8中,不再需要编写这样的循环样板代码。实际上,可以提供一个lambda表达式更新变量,它会为你完成更新。对于这个例子,我们可以调用:
largest.updateAndGet(x -> Math.max(x, observed));
或
largest.accumulateAndCet(observed, Math::max);
accumulateAndGet方法利用一个二元操作符来合并原子值和所提供的参数。还有getAndUpdate和getAndAccumulate方法可以返回原值。
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。JavaSE8提供了LongAdder和LongAccumulator类来解决这个问题。LongAdder包括多个变量(加数),其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。性能会有显著的提升。
如果认为可能存在大量竞争,只需要使用LongAdder而不是AtomicLong。方法名稍有区别。调用increment让计数器自增,或者调用add来增加一个量,或者调用sum来获取总和。
final LongAdder adder=new LongAdder();
for (. . .)
pool.submit(()->{
while (. . .) {
...
if (.. .) adder.increment();
}
});
...
long total=adder.sum();
LongAccumulator将这种思想推广到任意的累加操作。在构造器中,可以提供这个操作以及它的零元素。要加人新的值,可以调用accumulate。调用get来获得当前值。下面的代码可以得到与LongAdder同样的效果:
LongAccumulatoradder=newLongAccumulator(Long::sum,0);//Insomethread...adder.accumulate(value);
在内部,这个累加器包含变量a1,a2,…,an。每个变量初始化为零元素(这个例子中零元素为0)。调用accumulate并提供值v时,其中一t*变量会以原子方式更新为巧ai=a,op v,这里op是中缀形式的累加操作。在我们这个例子中,调用accumulate会对某个i计算ai=ai+v。get的结果是a1 op a2 op...op an.在我们的例子中,这就是累加器的总和:a,+〜+…+a,。如果选择一个不同的操作,可以计算最小值或最大值。一般地,这个操作必须满足结合律和交换律。这说明,最终结果必须独立于所结合的中间值的顺序。
另外DoubleAdder和DoubleAccumulator也采用同样的方式,只不过处理的是double值。