且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《Java安全编码标准》一1.7 并发性、可见性和内存

更新时间:2022-08-18 10:33:10

1.7 并发性、可见性和内存

可以在不同线程之间共享的内存称为共享内存(shared memory)或内存堆(heap memory)。本节使用变量(variable)这个名词来代表字段和数组元素[JLS2005]。在不同的线程***享的变量称为共享变量。所有的实例字段、静态字段以及数组元素作为共享变量存储在共享内存中。局部变量、形式方法参数以及异常例程参数是从来不能在线程之间共享的,不会受到内存模型的
影响。
在现代多处理器共享内存的架构下,每个处理器有一个或多个层次的缓存,会定期地与主存储器进行协同,如图1-3所示。
之所以开放对共享变量的数据写入会带来问题,是因为在共享变量中的数值是会被缓存的,而且把这些数值写入主存会有延迟。然后,其他的线程可能会读取这个变量的过时的数值。
更多的顾虑不仅在于并行执行的代码通常是交错的,同时也在于编译器或运行时系统会对执行语句进行重新排序来优化性能。这会导致执行次序很难从源代码中得到验证。不能记录可能的重排序,这是一个常见的数据竞态的来源。

《Java安全编码标准》一1.7  并发性、可见性和内存

举例来说,a和b是两个全局(共享)变量或实例域,而r1和r2是两个局部变量,r1和r2是不能被其他的线程读取的。在初始化状态下,令a=0,b=0。
《Java安全编码标准》一1.7  并发性、可见性和内存

在线程1中,完成两个赋值:a=10和r1=b,这两个赋值是不相关的,所以编译器在编译的时候,运行时系统可以任意安排它们的执行次序。这两个赋值在线程2中也有可能是任意安排次序的。尽管可能看起来难以理解,但是Java的内存模型允许读取一个刚刚写入的数值,而这次写入显然与执行的次序有关。
以下是在实际赋值时可能的执行次序:
《Java安全编码标准》一1.7  并发性、可见性和内存

在这个次序中,r1和r2分别读取变量b和a的初始值,但它们实际上希望得到的是更新过的值:20和10,而以下是实际赋值时另一种可能的执行次序:
执行次序(时间) 线程号 赋值操作 赋值 备注
《Java安全编码标准》一1.7  并发性、可见性和内存

在这个次序中,r1和r2读取b和a的值,b和a的值分别是在步骤4和步骤3赋予的,甚至是在对应的这些步骤被执行之前的那些语句赋予的。
如果能够明确代码可能的执行次序,那么对于代码的正确性,会有更大的把握。
当语句在一个线程中依次执行的时候,由于存在缓存,会使最新的数值没有在主存中体现。
《Java语言规范》(Java Language Specification JLS)中定义了Java内存模型(Java Memory Model, JMM),它为Java开发人员提供了一定程度的保障。JMM在定义的行为包括变量的读写、锁定和解锁的监视、线程的开始和会合。JMM对在程序中的所有动作定义了一种称为happens-before的部分次序化动作。它能保证一个线程执行时动作B可以看到动作A的执行结果,例如,可以说A和B的关系是一种happens-before的关系,A在B之前发生。
根据JSL17.4.5节对“Happens-before”的描述:
1)对监视器的解锁需要happens-before每一个接下来的对监视器的锁定。
2)对一个volatile域的写入需要happens-before每一个接下来的对该域的读取。
3)对一个线程的Thread.start()调用需要happens-before对这个启动线程的任何操作。
4)对一个线程的所有操作,需要happens-before从该线程Thread.join()引起的其他任何线程的正常返回。
5)任何对象的默认初始化需要happens-before该程序的任何其他操作(除了初始化的写入操作之外)。
6)一个线程对另一个线程的中断需要happens-before被中断线程检测到该中断。
7)一个对象的构造方法的结束需要happens-before这个对象的销毁器的开始。
在两个操作不存在happens-before关系的时候,JVM可以对它们的执行重新排序。当一个变量被至少一个线程写入,并且被至少一个线程读取的时候,如果这些读写不存在happens-before关系,数据的竞态会出现。正确同步的程序是不会出现数据的竞态的。JMM可以通过同步程序来保证其次序是一致的。次序一致是指任何执行结果都是一样的,比如当所有的线程按照任何特定的顺序对一个共享数据执行读写,这个序列中对每个线程的操作都是程序指定的顺序[Tanenbaum 2003]时,它们的执行结果都是同样的。换句话说:
1)每个线程都执行读和写操作,并把这些操作按照线程执行的次序进行排列(线程顺序)。
2)以某种方式安排这些操作,使它们在执行次序上是happens-before关系。
3)读操作必须返回最新写入的数据,在整个程序执行次序中,可以保证序列化的一致性。
4)这意味着任何线程都可以看到同样的对共享变量进行访问的次序。
如果程序的次序被遵从并且所有数据读取符合内存模型,那么对于实际的指令执行和内存读写次序来说,会有所不同。这使得开发人员可以理解他们编写的程序的语义,并且允许编译器开发者和虚拟机的实现有不同的优化方式[JPL 2006]。
这一系列并发原语可以帮助开发人员对多线程程序的语义有所理解。

1.7.1 关键词volatile

如果声明一个共享变量为volatile,那么可以保证它的可见性并且限制对访问它的操作进行重新排序。比如递增该变量的时候,不能保证volatile访问这个操作组合的原子性。因而,当必须保证操作组合的原子性时,是不能够使用volatile的(可以参考CON02-J规则获取详细信息)。
声明一个volatile变量即建立一种happens-before关系,例如,当一个线程写入一个volatile变量后,随后读取该变量的线程总会看到这个写入线程。写入这个volatile变量之前执行的语句happens-before任何对这个volatile变量的读操作。
考虑两个线程执行以下语句的情况,如图1-4所示。
线程1和线程2存在happens-before关系,因为线程2不能在线程1结束之前开始。

《Java安全编码标准》一1.7  并发性、可见性和内存

在这个例子中,语句3写入一个volatile变量,语句4(在线程2中)读取该volatile变量。这个读取可以得到语句3最新写入的数据(对同一个变量v)。
对volatile的读与写操作不能重新排序,要么依次读写它,要么使用非volatile变量。当线程2读取volatile变量的时候,它会得到所有的写入结果,而这些写入结果是在线程1写入该volatile变量之前发生的。由于需要相对有力的对volatile特性的保证,其性能开销几乎和同步是一样的。
在前面的例子中,并不能保证同一个程序中的两条语句按照它们在程序中出现的次序执行。如果在这两个语句之间不存在happens-before关系的话,它们可能会被编译器以任意的次序进行重排。
表1-2总结了所有对volatile和非volatile变量进行重排序的可能性。其中的load/store操作可以和read/write操作对应[Lea 2008]。
注意,如果在变量上加上volatile关键词的话,它会保证可见性和执行次序。也就是说,它仅应该适用于原始字段和对象引用。如果实际成员是一个对象引用本身,可以保证这一点;如果是一个对象,而它的引用是一个volatile类型,那么这一点是不能保证的。因而,声明一个对象引用是volatile不足以保证对所引用的成员的改变是可见的。这样的话,一个线程可能不能读取另一个线程对这个引用对象成员字段最新写入。此外,当一个引用是可变的并且不是线程安全,那么其他线程可能只能看到一个对象只是部分地被创建,或者这个对象会处在一个(临时)不一致的状态当中[Goetz 2007]。然而,当一个引用是不可变的时候,声明该引用是volatile已经足够保证该引用成员的可见性。
《Java安全编码标准》一1.7  并发性、可见性和内存

1.7.2 同步

一个正确进行同步的程序,可以保证执行的一致次序,并且不会产生数据竞态情况。下面的例子通过使用一个非volatile变量x和一个volatile变量b来说明如何没有正确同步。
《Java安全编码标准》一1.7  并发性、可见性和内存

在这个例子中,有两种序列上一致的执行次序。
《Java安全编码标准》一1.7  并发性、可见性和内存

在第一种情况下,步骤1和步骤2总是发生在步骤3和步骤4之前,这是一种happens-before关系。然而,第二种顺序一致的执行情况在任何步骤之间都缺乏happens-before关系。因此,这个例子中存在数据的竞态。
正确的可见性可以保证多个线程在访问共享数据时,得到相互之间的结果,但是不能在每一个线程读写数据的时候、建立起次序。正确的同步可以实现正确的可见性,并且可以保证线程写以特定的次序访问数据。例如,从下面的代码可以看出,线程1的所有操作都在线程2的所有操作之前执行,这时保证了一个一致的执行次序。

class Assign {
?public synchronized void doSomething() {
???// If in Thread 1, perform Thread 1 actions
???x = 1;
???y = 2;
???// If in Thread 2, perform Thread 2 actions
???r1 = y;
???r2 = x;
?}
}

使用同步的时候,不需要声明变量y是volatile的。同步涉及取得锁、执行操作、释放锁等过程。在前面的例子中,doSomething()方法需要获得一个类对象Assign的内部锁。这个例子同样可以使用块同步来实现。

class Assign {
?public void doSomething() {
??synchronized (this) {
????// If in Thread 1, perform Thread 1 actions
????x = 1;
????y = 2;
????// If in Thread 2, perform Thread 2 actions
????r1 = y;
????r2 = x;
??}
?}
}

这两个例子都使用了内部锁。对象的内部锁也可以用作监视器。释放一个对象的内部锁总是在下一次获得该对象的内部锁之前发生,这是一种happens-before关系。

1.7.3 java.util.concurrent类

原子类 volatile变量对保证可见性很有用。但是,它不能保证原子性。同步可以保证原子性,但它们会产生上下文切换的额外开销,并且经常会出现锁竞争。java.util.concurrent.atomic包中的原子类提供了一种机制,可以在同时需要保证原子性时,在大多数环境下减少锁竞争。根据Goetz及其同事的研究,“在低和中等的竞争时,原子操作提供更好的可扩展性;在高竞争时,锁操作可以避免高竞争”[Goetz 2006a]。
atomic类开放了通用的功能接口,因而开发人员可以充分利用现代处理器的compare-swap指令提供的执行效率。例如,AtomicInteger.incrementAndGet()方法支持对一个变量的原子加,其他高层方法如 java.util.concurrent.atomic.Atomic*.compareAndSet()(其中Atomic可以是Integer、Long或Boolean类型)为开发者提供了一个简单的抽象接口,通过这个接口,同样可以方便地使用处理器级别的指令。
在?java.util.concurrent辅助包中,倾向于使用volatile变量,而不是使用传统的诸如synchronized同步方法,如synchronized关键字和volatile变量,因为这些辅助包抽象了底层的细节,提供了一个更简单且更少错误的API,这样更容易扩展,并在一定的策略下可以加强其作用。
执行器框架 通过使用执行器框架,java.util.concurrent包提供了任务并发执行的机制。这些任务可以是由实现了Runnable?或者Callable接口的类来封装的一个逻辑执行单元。这个执行器框架将任务提交与底层的任务管理和调度细节分离开。它还提供了线程池机制,通过这个线程池,在系统需要同时处理超过其处理能力的请求时,系统不致崩溃。
执行器框架的核心接口是Executor接口,它扩展自?ExecutorService接口。ExecutorService?接口提供了线程池的终止机制,并且可以获得任务的返回值。ExecutorService?还被ScheduledExecutorService扩展了,这个ScheduledExecutorService?接口提供了可以让运行中的任务周期性或延时执行。Executor类提供了若干工厂和辅助方法,通过这些方法可以提供Executor、ExecutorService和其他接口需要的通用配置。例如,Executors.newFixedThreadPool()方法可以返回确定大小的线程池,为在线程池中并发执行的任务数目确定一个上限,并在线程池满载时,维护一个任务队列。线程池的基本(实际)实现是由ThreadPoolExecutor类来完成的。这个类可以被实例化来定制任务执行策略。
显式锁 java.util.concurrent包中的ReentrantLock?类提供了隐含锁所没有的功能特性。举例来说,调用ReentrantLock.tryLock()方法会立即返回持有锁的另一个线程对象。在JMM的定义中,获取或释放一个ReentrantLock?对象与获取或释放一个隐含锁是一样的。