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

简介: 本节书摘来自华章出版社《Java安全编码标准》一书中的第1章,第1.7节,作者 (美)Fred Long,Dhruv Mohindra,Robert C. Seacord,Dean F. Sutherland,David Svoboda,更多章节内容可以访问云栖社区“华章计算机”公众号查看

1.7 并发性、可见性和内存

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

image

举例来说,a和b是两个全局(共享)变量或实例域,而r1和r2是两个局部变量,r1和r2是不能被其他的线程读取的。在初始化状态下,令a=0,b=0。
image

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

在这个次序中,r1和r2分别读取变量b和a的初始值,但它们实际上希望得到的是更新过的值:20和10,而以下是实际赋值时另一种可能的执行次序:
执行次序(时间) 线程号 赋值操作 赋值 备注
image

在这个次序中,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结束之前开始。

image

在这个例子中,语句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已经足够保证该引用成员的可见性。
image

1.7.2 同步

一个正确进行同步的程序,可以保证执行的一致次序,并且不会产生数据竞态情况。下面的例子通过使用一个非volatile变量x和一个volatile变量b来说明如何没有正确同步。
image

在这个例子中,有两种序列上一致的执行次序。
image

在第一种情况下,步骤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?对象与获取或释放一个隐含锁是一样的。

相关文章
|
20天前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
122 29
JVM简介—1.Java内存区域
|
4月前
|
存储 缓存 安全
Java内存模型深度解析:从理论到实践####
【10月更文挑战第21天】 本文深入探讨了Java内存模型(JMM)的核心概念与底层机制,通过剖析其设计原理、内存可见性问题及其解决方案,结合具体代码示例,帮助读者构建对JMM的全面理解。不同于传统的摘要概述,我们将直接以故事化手法引入,让读者在轻松的情境中领略JMM的精髓。 ####
75 6
|
12天前
|
Java 数据库
【YashanDB知识库】kettle同步大表提示java内存溢出
在数据导入导出场景中,使用Kettle进行大表数据同步时出现“ERROR:could not create the java virtual machine!”问题,原因为Java内存溢出。解决方法包括:1) 编辑Spoon.bat增大JVM堆内存至2GB;2) 优化Kettle转换流程,如调整批量大小、精简步骤;3) 合理设置并行线程数(PARALLELISM参数)。此问题影响所有版本,需根据实际需求调整相关参数以避免内存不足。
|
3月前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
63 0
|
4月前
|
存储 算法 Java
Java内存管理深度剖析与优化策略####
本文深入探讨了Java虚拟机(JVM)的内存管理机制,重点分析了堆内存的分配策略、垃圾回收算法以及如何通过调优提升应用性能。通过案例驱动的方式,揭示了常见内存泄漏的根源与解决策略,旨在为开发者提供实用的内存管理技巧,确保应用程序既高效又稳定地运行。 ####
|
1月前
|
存储 IDE Java
java设置栈内存大小
在Java应用中合理设置栈内存大小是确保程序稳定性和性能的重要措施。通过JVM参数 `-Xss`,可以灵活调整栈内存大小,以适应不同的应用场景。本文介绍了设置栈内存大小的方法、应用场景和注意事项,希望能帮助开发者更好地管理Java应用的内存资源。
52 4
|
1月前
|
人工智能 监控 安全
Java智慧工地(源码):数字化管理提升施工安全与质量
随着科技的发展,智慧工地已成为建筑行业转型升级的重要手段。依托智能感知设备和云物互联技术,智慧工地为工程管理带来了革命性的变革,实现了项目管理的简单化、远程化和智能化。
55 5
|
1月前
|
Java Shell 数据库
【YashanDB 知识库】kettle 同步大表提示 java 内存溢出
【问题分类】数据导入导出 【关键字】数据同步,kettle,数据迁移,java 内存溢出 【问题描述】kettle 同步大表提示 ERROR:could not create the java virtual machine! 【问题原因分析】java 内存溢出 【解决/规避方法】 ①增加 JVM 的堆内存大小。编辑 Spoon.bat,增加堆大小到 2GB,如: if "%PENTAHO_DI_JAVA_OPTIONS%"=="" set PENTAHO_DI_JAVA_OPTIONS="-Xms512m" "-Xmx512m" "-XX:MaxPermSize=256m" "-
|
3月前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
3月前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
66 8