<高级-2> java内存模型
程序员文章站
2022-07-12 18:48:02
...
抓住4月尾巴:)
前面介绍的所有原则,比如安全发布,同步策略的规范以及一致性等,他们的安全性都来自于JMM(java内存模型, java memory model)。
1.1 什么是内存模型,为什么需要它
假设一个线程为变量aVariable赋值:
aVariable = 3;
内存模型需要解决这个问题:在什么条件下,读取aVariable的线程将看到这个值为3?在缺少同步的条件下,会有许多因素使得线程无法立即,甚至永远,看到另一个线程的操作结果。
(1)在编译器中生成的指令顺序,可以与源代码的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;
(2)处理器可以采用乱序或并行等方式来执行指令;
(3)缓存(每个工作线程都有自己的缓存,多处理器时每个处理器也有自己的缓存)可能会改变将写入变量提交到主内存的次序;
(4)而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行(如果没有使用正确的同步)。
JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM在设计时就在可预测性和程序的易开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。
1.1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。
在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使java开发人员无需关心不同架构上内存模型之间的差异,java还提供了自己的内存模型,并且jvm通过在适当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。
1.1.2 重排序
下面程序说明了在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。这个程序就可以输出(1,0),(1,1),(0,1),(0,0)。前三种情况很容易想象,
1.1.3 java内存模型简介
java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序总所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行B操作的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并且表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
Happens-before规则包括:
程序顺序规则:如果程序中操作A在操作B之前,那么在线程中A操作在B操作之前执行。
监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。推荐: http://www.cnblogs.com/dolphin0520/p/3920373.html
线程启动规则:在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行。
线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false.
中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。
传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A也必须在操作C之前执行。
虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放等操作,以及volatile变量的读取和写入操作,都满足全序关系。
1.2 发布
前面介绍的如何安全地发布一个对象,他们的安全性都来自于JMM提供的保证,而造成不正确发布的真正原因,就是“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种Happens-Before排序。
1.2.1 不安全的发布
当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。
(1)在初始化一个新的对象时,需要写入多个变量,即新对象中的各个域。
(2)同样,在发布一个引用时也需要写入一个变量,即新对象的引用。
依据上面两步,如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的无效值,即一个被部分构造的对象。
错误的延迟初始化将导致不正确的发布。
这是最常见的不安全的延迟初始化方式。
除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用对象的线程开始使用之前执行。
1.2.2 安全初始化模式
有时候我们需要推迟一些高开销的对象初始化操作,并且只有当使用这些对象时才进行初始化,但我们也看到了在误用延迟初始化时导致的问题。
给上面的程序加上同步约束,就成了线程安全的初始化。
在初始器中采用了特殊的方式来处理静态域(或在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化器(也就是static块)是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用前。由于JVM将在初始化阶段获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作时可见的以及避免数据破坏。
如下还有两种方式可以安全的构造对象。一个是结合静态初始化技术提前初始化对象,一个是使用延迟初始化占位类模式(使用一个专门的类来初始化对象)。
JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。
看到没,第二种方法既做到了延迟初始化,又不需要使用同步,非常实用!
1.2.3 双重检查锁(DCL, double check lock)
任何一本介绍并发的书都会讨论声名狼藉的双重检查锁。
上面这样写,线程可能看到一个仅被部分初始化构造的Resource。DCL的真正问题在于,当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕事情只是看到一个失效值。然而,实际情况远比这更糟糕,线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态。
在JMM的后续版本(jdk5.0及以后),如果把resource声明为volatile类型,那么DCL就是安全的,并且对性能的影响很小。然而,DCL的这种使用方法已经被广泛废弃了,延迟初始化占位类模式能带来同样的优势,而且更容易理解。推荐: http://www.iteye.com/topic/652440
1.3 初始化过程中的安全性
如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管是如何发布的,甚至通过某种数据竞争来发布。也即是如果前面的例子Resource是不可变的,那么不安全的延迟初始化写法实际上也是安全的。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应该为不可变对象的值将会发生改变。安全性架构依赖于String的不可变形,如果缺少了初始化安全性,那么可能会导致一个安全漏洞,从而使恶意代码绕过安全检查。
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作都将被冻结。并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作仪器被重排序。
初始化安全性意味着,下面程序的SafeStates可以安全地发布,即使通过不安全的延迟初始化,或者在没有同步的情况下将SafeStates的引用放到一个公有的静态域,或者没有使用同步以及依赖于非线程安全的HashSet.
然而,许多对SafeStates的细微修改都可能破坏它的线程安全性。如果states不是final类型,或者存在除构造函数以外的其他方法能修改states,那么初始化安全性将无法确保。如果在SafeStates中还有其他的非final域,那么其他线程仍然可能看到这些域上不正确的值。这也导致了对象在构造函数中逸出。
初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。
前面介绍的所有原则,比如安全发布,同步策略的规范以及一致性等,他们的安全性都来自于JMM(java内存模型, java memory model)。
1.1 什么是内存模型,为什么需要它
假设一个线程为变量aVariable赋值:
aVariable = 3;
内存模型需要解决这个问题:在什么条件下,读取aVariable的线程将看到这个值为3?在缺少同步的条件下,会有许多因素使得线程无法立即,甚至永远,看到另一个线程的操作结果。
(1)在编译器中生成的指令顺序,可以与源代码的顺序不同,此外编译器还会把变量保存在寄存器而不是内存中;
(2)处理器可以采用乱序或并行等方式来执行指令;
(3)缓存(每个工作线程都有自己的缓存,多处理器时每个处理器也有自己的缓存)可能会改变将写入变量提交到主内存的次序;
(4)而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行(如果没有使用正确的同步)。
JMM规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM在设计时就在可预测性和程序的易开发性之间进行了权衡,从而在各种主流的处理器体系架构上能实现高性能的JVM。
1.1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期地与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一个存储位置上看到不同的值。
在架构定义的内存模型中将告诉应用程序可以从内存系统中获得怎样的保证,此外还定义了一些特殊的指令(称为内存栅栏或栅栏),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使java开发人员无需关心不同架构上内存模型之间的差异,java还提供了自己的内存模型,并且jvm通过在适当的位置上插入内存栅栏来屏蔽在JMM与底层平台内存模型之间的差异。
1.1.2 重排序
下面程序说明了在没有正确同步的情况下,即使要推断最简单的并发程序的行为也很困难。这个程序就可以输出(1,0),(1,1),(0,1),(0,0)。前三种情况很容易想象,
1.1.3 java内存模型简介
java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的启动和合并操作。JMM为程序总所有的操作定义了一个偏序关系,称之为Happens-Before。要想保证执行B操作的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。如果两个操作之间缺乏Happens-Before关系,那么JVM可以对它们任意地排序。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读操作和写操作之间没有依照Happens-Before来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并且表现出串行一致性,这意味着程序中的所有操作都会按照一种固定的和全局的顺序执行。
Happens-before规则包括:
程序顺序规则:如果程序中操作A在操作B之前,那么在线程中A操作在B操作之前执行。
监视器锁规则:在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
volatile变量规则:对volatile变量的写入操作必须在对该变量的读操作之前执行。推荐: http://www.cnblogs.com/dolphin0520/p/3920373.html
线程启动规则:在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行。
线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false.
中断规则:当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出InterruptedException,或者调用isInterrupted和interrupted)。
终结器规则:对象的构造函数必须在启动该对象的终结器之前执行完成。
传递性:如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A也必须在操作C之前执行。
虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放等操作,以及volatile变量的读取和写入操作,都满足全序关系。
1.2 发布
前面介绍的如何安全地发布一个对象,他们的安全性都来自于JMM提供的保证,而造成不正确发布的真正原因,就是“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种Happens-Before排序。
1.2.1 不安全的发布
当缺少Happens-Before关系时,就可能出现重排序问题,这就解释了为什么在没有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。
(1)在初始化一个新的对象时,需要写入多个变量,即新对象中的各个域。
(2)同样,在发布一个引用时也需要写入一个变量,即新对象的引用。
依据上面两步,如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的无效值,即一个被部分构造的对象。
错误的延迟初始化将导致不正确的发布。
这是最常见的不安全的延迟初始化方式。
除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用对象的线程开始使用之前执行。
1.2.2 安全初始化模式
有时候我们需要推迟一些高开销的对象初始化操作,并且只有当使用这些对象时才进行初始化,但我们也看到了在误用延迟初始化时导致的问题。
给上面的程序加上同步约束,就成了线程安全的初始化。
在初始器中采用了特殊的方式来处理静态域(或在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证。静态初始化器(也就是static块)是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用前。由于JVM将在初始化阶段获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作时可见的以及避免数据破坏。
如下还有两种方式可以安全的构造对象。一个是结合静态初始化技术提前初始化对象,一个是使用延迟初始化占位类模式(使用一个专门的类来初始化对象)。
JVM将推迟ResourceHolder的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来初始化Resource,因此不需要额外的同步。
看到没,第二种方法既做到了延迟初始化,又不需要使用同步,非常实用!
1.2.3 双重检查锁(DCL, double check lock)
任何一本介绍并发的书都会讨论声名狼藉的双重检查锁。
上面这样写,线程可能看到一个仅被部分初始化构造的Resource。DCL的真正问题在于,当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕事情只是看到一个失效值。然而,实际情况远比这更糟糕,线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于无效或错误的状态。
在JMM的后续版本(jdk5.0及以后),如果把resource声明为volatile类型,那么DCL就是安全的,并且对性能的影响很小。然而,DCL的这种使用方法已经被广泛废弃了,延迟初始化占位类模式能带来同样的优势,而且更容易理解。推荐: http://www.iteye.com/topic/652440
1.3 初始化过程中的安全性
如果能确保初始化过程的安全性,那么就可以使得被正确构造的不可变对象在没有同步的情况下也能安全地在多个线程之间共享,而不管是如何发布的,甚至通过某种数据竞争来发布。也即是如果前面的例子Resource是不可变的,那么不安全的延迟初始化写法实际上也是安全的。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本应该为不可变对象的值将会发生改变。安全性架构依赖于String的不可变形,如果缺少了初始化安全性,那么可能会导致一个安全漏洞,从而使恶意代码绕过安全检查。
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,构造函数对final的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作都将被冻结。并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作仪器被重排序。
初始化安全性意味着,下面程序的SafeStates可以安全地发布,即使通过不安全的延迟初始化,或者在没有同步的情况下将SafeStates的引用放到一个公有的静态域,或者没有使用同步以及依赖于非线程安全的HashSet.
然而,许多对SafeStates的细微修改都可能破坏它的线程安全性。如果states不是final类型,或者存在除构造函数以外的其他方法能修改states,那么初始化安全性将无法确保。如果在SafeStates中还有其他的非final域,那么其他线程仍然可能看到这些域上不正确的值。这也导致了对象在构造函数中逸出。
初始化安全性只能保证通过final域可达的值从构造过程完成时开始的可见性。对于通过非final域可达的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。