Java核心技术 卷1-总结-18
- 同步
- Volatile域
- final变量
- 原子性
- 死锁
- 线程局部变量
- 锁测试与超时
- 读/写锁
同步
Volatile域
- 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
- 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变。
如果使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取,或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的,此时必须使用同步。
volatile
关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile
,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。
例如,假定一个对象有一个布尔标记done,它的值被一个线程设置却被另一个线程查询,可以使用锁:
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
使用锁并不合适,如果另一个线程已经对该对象加锁,isDone
和setDone
方法可能阻塞。在这种情况下,将域声明为volatile
是合理的:
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }
注意:Volatile变量不能提供原子性。例如,方法
public void flipDone() { done = !done; } // not atomic
不能确保翻转域中的值。不能保证读取、翻转和写入不被中断。
final变量
使用锁或volatile
修饰符,可以从多个线程安全地读取一个域。还有一种情况可以安全地访问一个共享域,即这个域声明为final
。考虑以下声明:
final Map<String, Double> accounts = new HashMap<>();
其他线程会在构造函数完成构造之后才看到这个accounts
变量。如果不使用final
,就不能保证其他线程看到的是accounts
更新后的值,它们可能都只是看到null
,而不是新构造的HashMap
。
注意:对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。
原子性
假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为volatile
。
java.util.concurrent.atomic
包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。 例如,AtomicInteger类提供了方法incrementAndGet
和decrementAndGet
,它们分别以原子方式将一个整数自增或自减。例如,可以安全地生成一个数值序列,如下所示:
public static AtomicLong nextNumber = new AtomicLong();
// In some thread...
long id m 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!
这个更新不是原子的。实际上,应当在一个循环中计算新值和使用compareAndSet:
do {
oldvalue = largest.get();
newValue = Math.max(oldValue,observed);
} while(!largest.compareAndSet(oldValue, newValue));
如果另一个线程也在更新largest
,就可能阻止这个线程更新。这样一来,compareAndSet
会返回false
,而不会设置新值。
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。
死锁
锁和条件不能解决多线程中的所有问题。考虑下面的情况:
- 线程1和线程2分别向账户1和账户2转入大于自身余额的金额,由于余额都不足以进行转账,两个线程都无法执行下去。
账户1:$200
账户2:$300
线程1:从账户1转移$300到账户2
线程2:从账户2转移$400到账户1
如图所示,线程1和线程2都被阻塞了。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。这样的状态称为死锁(deadlock)。
- 导致死锁的另一种途径是让第
i
个线程负责向第i
个账户存钱,而不是从第i
个账户取钱。这样一来,有可能将所有的线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。 - 还有一种很容易导致死锁的情况:将
signalAll
方法转换为signal
,该程序最终会挂起。signalAll
通知所有等待增加资金的线程,与此不同的是signal
方法仅仅对一个线程解锁。如果该线程不能继续运行,所有的线程可能都被阻塞。
Java编程语言中没有任何东西可以避免或打破死锁现象。必须仔细设计程序,以确保不会出现死锁。
线程局部变量
使用ThreadLocal
辅助类为各个线程提供各自的实例。 例如,SimpleDateFormat
类不是线程安全的。假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
datcFormat
使用的内部数据结构可能会被并发的访问所破坏。可以使用同步,但开销很大;或者也可以在需要时构造一个局部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
辅助类为各个线程提供一个单独的生成器,不过Java 还另外提供了一个便利类ThreadLocalRandom
。只需要做以下调用:
int random = ThreadLocalRandom.current().nextInt(upperBound);
ThreadLocalRandom.current()
调用会返回特定于当前线程的Random
类实例。
T get()
得到这个线程的当前值。如果是首次调用get
,会调用initialize
来得到这个值。
protected initialize()
应覆盖这个方法来提供一个初始值。默认情况下,这个方法返回null。
void set(T t)
为这个线程设置一个新值。
void remove()
删除对应这个线程的值。
static <S> ThreadLocal<S> withInitial(Supplier<? extends S>
supplier)
创建一个线程局部变量,其初始值通过调用给定的supplier生成。
锁测试与超时
线程在调用lock
方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。tryLock
方法试图申请一个锁,在成功获得锁后返回true
,否则,立即返回false
,线程可以立即离开去做其他事情。
if (myLock.tryLock()) {
// now the thread owns the lock
try { ... }
finally{myLock.unlock();}
} else
// do something else
调用tryLock
时,可以使用超时参数:
if (myLock.tryLock(100, TimeUnit.MILLISECONDS))...
TimeUnit
是一个枚举类型,可以取的值包括SECONDS
、MILLISECONDS
、MICROSECONDS
和NANOSECONDS
。
lock
方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么,lock
方法就无法终止。
如果调用带有用超时参数的tryLock
,那么如果线程在等待期间被中断,将抛出InterruptedException
异常。这是一个非常有用的特性,因为允许程序打破死锁。
在等待一个条件时,也可以提供一个超时:
myCondition.await(100, TimeUnit.MILLISECONDS))
如果一个线程被另一个线程通过调用signalAll
或signal
激活,或者超时时限已达到,或者线程被中断,那么await
方法将返回。
如果等待的线程被中断,await
方法将抛出一个InterruptedException
异常。
java.util.concurrent.locks.Lock 5.0
boolean tryLock()
尝试获得锁而没有发生阻塞;如果成功返回真。这个方法会抢夺可用的锁,即使该锁有公平加锁策略,即便其他线程已经等待很久也是如此。
boolean tryLock(long time, TimeUnit unit)
尝试获得锁,阻塞时间不会超过给定的值;如果成功返回true
。
void lockInterruptibly()
获得锁,但是会不确定地发生阻塞。如果线程被中断,抛出一个InterruptedException
异常。
读/写锁
java.util.concurrent.locks
包定义了两个锁类:ReentrantLock
类和ReentrantReadWriteLock
类。如果很多线程从一个数据结构读取数据而很少线程修改其中数据的,在这种情况下,允许对读者线程共享访问是合适的。但是,写者线程依然必须是互斥访问的。
下面是使用读/写锁的必要步骤:
(1)构造一个ReentrantReadWriteLock
对象:
private ReentrantReadWriteLock rwl = new ReentrantReadwriteLock();
(2)抽取读锁和写锁:
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
(3)对所有的获取方法加读锁:
public double getTotal Balance() {
readLock.lock();
try {...}
finally { readLock.unlock();}
}
4)对所有的修改方法加写锁:
public void transfer(...) {
writeLock.lock();
try{...}
finally { writeLock.unlock();}
}