前面的笔记记载过,可以通过同步的方式,保证一个共享变量即使在高并发场景下依然能保证一致性。例如可以通过synchronized关键字或者JUC包的atomic包中对象保证对变量的原子访问等等。
内存可见性
其实,在并发编程中,除了对共享变量的原子访问要求之外,还有另外一个特别重要的特性----内存可见性。什么是“内存可见性”,说白了就是一个线程对一个共享变量的更新,可以让另一个线程看到,这就是内存可见性。
由内存可见性所引发出的问题千奇百怪,在实际开发中,我们经常遇到的一些棘手的问题可能就和内存可见性有关。例如,当我们写好了一段代码,在本地运行看起来没什么问题,但是当我们把它提供给其他人使用时。却出现了各种各样奇怪的问题。当然了,还有另外一个JVM的特性----重排序,会使得我们遇到的问题变得更加扑朔迷离。
因此,当我们没有解决内存可见性的问题时,有可能会使得我们获取到的数据是一个过期值。也就是说,我们有可能获取到的值不是最近一次更新的值。虽然这个值可能不是我们想要的,但是最起码能保证这个值,是一个真实的数据,而不是凭空产生的。这在某些概念中叫做“最低限度的安全”。这个概念在几乎所有变量中都适用,除了long和double类型的数据,因为JVM允许将64位的读或写划分为两个32位的操作。而如果读写是在不同的线程的话,这就有可能造成了一个读取的数据一半是真实值一半是过期值。
如何解决上面说的问题呢?第一种,就是使用锁,这种不用过多介绍了。第二种,就是使用Volatile关键字。
Volatile关键字的作用也不难理解,当更新用这个关键字修饰的变量时,会使得相应的更新以可预见的方式告知其他线程。当我们使用Volatile关键字修饰一个变量后,JVM就会知道这个变量是一个共享变量,使得对这个变量的操作不会被重排序,同时也不会把这个变量缓存起来。保证读取一个Volatile类型的变量时,总会返回由某一线程所写入的最新值。我们要注意的一点是,Volatile关键字并不会对操作加锁,只是告知线程这个变量是一个共享的数据,因此可以理解为它的机制是一个轻量级的同步机制。
由于同步机制的限制,在使用Volatile关键字的时候要慎用。例如我们可以在只有单一的线程对共享变量进行修改时,使用Volatile关键字。
因此我们可以总结为:枷锁可以保证可见性与原子性,Volatile变量只能保证可见性。
避免访问共享数据
我们知道,在使用共享、可变的数据要求使用同步。由于同步的种种限制,使得程序的性能受到了一部分影响。因此,在某些场景下,我们是希望避免使用同步机制的。一种手段,就是不共享数据。嘿,你打不到我。
这种技术,我们称之为“线程封闭”技术,实现线程封闭的方式有三种。
Ad-hoc限制:维护线程限制性的任务全部落在实现上的这种情况,这是一种极其考验设计的一种方式,因为我们永远不知道其他线程对于设计的访问是在哪种状态下。
栈限制:我们知道,在JVM的内存模型中,栈数据属于线程私有的。因此,当我们把所有的数据都限制在栈内存中,就自然而然的避免了数据的共享。这也很好理解,当我们把所有的属性都封闭在方法的内部变量里时,肯定就完全避免了同步的出现。
ThreadLocal
前面两种维护线程封闭的技术,需要开发人员编写详细的维护文档,以及谨慎的编码来保证栈中的数据没有被一些共享对象使用。同时,这两种方案,在使用上,很难有一种规范来遵守。
在线程封闭技术中,还有一种更加规范的、让人耳目一新的方式是,那就是耳熟能详的ThreadLocal。它会将每个线程的数据与线程关联起来,然后通过get和set的方式,对线程中的数据进行设置。例如,在数据库连接池的连接使用方式中,就可以使用ThreadLocal来实现。
在ThreadLocal的官方文档中,提供了生成线程ID的一种方式:
public class ThreadId {
private static final AtomicInteger nextId = new AtomicInteger(0);
private static final ThreadLocal<Integer> threadId =
new ThreadLocal<Integer>() {
@Override protected Integer initialValue() {
return nextId.getAndIncrement();
}
};
public static int get() {
return threadId.get();
}
}
我们可以通过这种方式,来设置我们的线程ID。