并发编程——同步块、监视器、volatile
同步块概念
每个Java对象都有一个锁。线程可以调用同步方法获得这个锁。还有另外的一种机制获取锁:进入一个同步块
;若线程进入如下这个块中:
synchronized (obj) {
critical section;
}
他就会得到obj的同步锁:
public class Bank {
private double[] accounts;
private Lock lock = new Object();
......
public void transefer(int from, int to, int amount) {
synchronized (lock) {
//an ad-hoc lock
accounts[from] -= amount;
accounts[to] += amount;
}
System.out.println(......);
}
}
在这里,创建了Lock对象只是为了使用每一个Java对象持有一个锁。
警告:使用同步代码块时,注意锁对象,例如下面的代码是有问题的:
private final String lock = "LOCK";
synchronized (lock) {.....] //dont lock on string
如果这个代码在同一个程序出现了两次,则锁将会是同一个对象,由于字符串字面量存在共享;这可能会导致死锁发生。
另外需要注意使用基本类型包装器作为一个锁;
private final Integer lock = new Integer(42);
构造器调用new Integer(8)已经被废弃了,若使用同一个魔法数两次,会导致出乎意外的共享锁。
若需要修改一个静态字段,会从特定的类上获取锁,而不是从getClass返回的数值获取:
synchronized (MyClass.class) {staticCounter++;} //OK
synchronized (getClass()) {staticCounter++;} //No
若一个子类调用包含这个代码块的方法,getClass会返回一个不同的Class对象!无法保证互斥!
在某些情况下,会使用一个对象的锁来实现额外的原子操作,这种做法叫做客户端锁定。
例如考虑Vector类,这是一个列表,方法是同步的。现在假设银行的余额都存储在Vector< Double>中。下面是transfer的一个方法原生实现:
public void transfer(Vector<Double> accounts, int from, int to, int account) {
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
System.out.println(....);
}
Vector类的get和set方法都是同步的,但是这对于我们没有什么帮助,一个线程完全可以在transfer方法中执行完第一个get调用之后被抢占,然后另一个线程可能会在相同的位置存储一个不同的数值。
不过我们可以截获这个锁:
public void transferI(Vector<Double> accounts, int from, int to, int amount) {
synchronized (accounts) {
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
}
}
该方法完全可行,但是完全依赖如下的事实:Vector类会对自己的所有更改器方法使用内部锁。
Java虚拟机对同步方法提供了内置支持;不过同步块会编译为很长的字节码序列来完成管理内部锁。
监视器概念
锁和条件是实现线程同步的强大工具,但是从严格意义上来说,他们并不是面向对象的。用Java语言描述监视器概念如下:
- 监视器是一个只包含了私有字段的类。
- 监视器类的每一个对象都有一个关联的锁。
- 所有的方法都是由这个锁锁定。换句话说,若客户端调用了obj.method(),则方法调用开始会自动获取obj对象对象的锁,并且在方法返回时自动释放这个锁。因为所有的字段都是私有的,这样的安排可以确保一个线程处理字段时候,没有其他的线程能够访问该字段。
- 锁可以有任意多个关联的条件。
监视器的早期版本只有一个单一条件,使用一种很优雅的语法。可以调用await accounts并且不适用额外的显式条件变量。不过研究表明,盲目的重新测试条件及其低效。
最好的办法还是调用await/notifyAll/notify
来访问条件变量。
注意,Java对象在以下3个方面不同于监视,削弱了线程安全性:
- 字段不要求是private。
- 方法不要求是synchronized。
- 内部锁对于客户来说是可用。
volatile字段
有时候,仅仅只是为了读写一两个实例字段而利用同步机制,所带来的性能开销有些得不偿失;
- 在多处理器的计算机可以暂存在寄存器或者本地内存缓冲中保存内存数值,结果就是,在不同的处理器上的线程可能看到的同一个内存位置有不同的数值。
- 编译器会改变指令执行的顺序从而得到更大的IO吞吐量。编译器不会选择可能改变代码语义的顺序,但是编译器有一个假设,认定内存数值只会在代码中有显式的修改指令时才会发生改变。不过,内存数值可能会被另一个线程改变。
若使用锁机制来保护可能被多个线程访问的代码,就不会存在这些问题。因为编译器必须遵守锁的要求,在必要时刷新输出本地缓存,而且不能够不适合的重排指令顺序。
注意,有如下同步格言:若写一个变量,而且这个变量接下来可能会被另一个线程读取,或者,若读取一个变量可能已经被另一个线程写入了数值,就必须使用同步。
volatile关键字 给实例字段提供了一种免锁机制。若声明一个字段给volatile,则编译器和虚拟机会考虑这个字段可能会被另一个线程并发更新。
假设有一个对象有一个Boolean标记了done,其数值由另一个线程设置,而且由另一个线程负责查询:
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { doen = true; }
或许在对象内部使用锁机制不够优秀,若另一个线程已经给该对象加锁了,则方法可能存在阻塞。若是这个问题,可以只给这个变量使用一个单独的锁,这太麻烦了。
因此综上,考虑给字段声明为volatile 就很合适。
编译器会插入合适的代码,确保一个线程对done变量做了修改,这个修改对读取变量的所有其他线程都可见。
volatile变量无法提供原子性。例如如下方法:
public void flipDone() { done != done; }
无法确保字段的数值取反,无法保证读取、写入和取反不会被中断。
final变量
除非使用锁或者volatile修饰符,否则无法从多个线程安全的读取一个字段。
另一个情况可以安全的访问一个共享字段:
final var accounts = new HashMap<String, Double>();
其他的线程会在构造器完成构造之后才能看到accounts这个变量。
若不使用final,则无法保证其他线程看到的accounts更新之后的数值,可能看到的都是null。不是新构造的HashMap<>()。
注意;映射的操作并不是线程安全的。如果多个线程更改和读取这个映射,则仍然需要进行同步操作。