- 浏览: 78576 次
文章分类
最新评论
-
kevinflynn:
...
ThreadLocal 源码分析 -
kevinflynn:
[url=aaaa][/url]
ThreadLocal 源码分析 -
kevinflynn:
学习到了 感谢楼主。
ThreadLocal 源码分析
1.首先要说下 Java 内存模型的抽象,JMM 规定了每个线程都有自己的本地内存,本地内存中存放的是主内存中
共享变量的拷贝. 现在线程 A 需要和线程 B 通讯,则需要通过 A 的本地内存,在 JMM 的控制下,到主内存,
然后从主内存到线程 B 的本地内存,这样就完成了一次通讯.
2.现代编译器为了提高提高性能,会对指令进行重排序. 重排序分为 3 类重排序.
(1)编译器优化的重排序
(2)指令级并行的重排序
(3)内存系统的重排序
因为重排序的问题,我们在编程的时候,是不是会碰到各种莫名其妙的问题?例如 i++,初始时 i=0,但是最终
两个线程的执行结果是不是都是 1?
为了解决可见性问题,Java 有如下策略:
(1)内存屏障
(2)happens-before
1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作
2.对于一个锁的解锁,happens-before 与随后对这个锁的加锁
3.对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读.
4.A happens-before B, B happens-before C, 那么 A happens-before C(传递性).
注意:happens-before 并不意味着一个操作必须先于另一个操作执行,而是说那个操作的结果对后一个操作可见.
对于有数据依赖关系的操作,单线程中是不会进行重排序的,但是在多线程中,数据依赖不被编译器考虑.
有数据依赖关系的:
读后写
写后读
写后写
3.as-if-serial 语义
as-if-serial 语义是说不管怎么重排序,单线程程序的执行结果不能被改变.
4.顺序一致性模型
顺序一致性模式是一个理论的参考模型,它为程序员提供了极强的内存可见性保证. 顺序一致性模型有两大特点:
**(1) 一个线程中的所有操作都是按照顺序的先后顺序执行的.
(2) 不管程序是否同步,所有线程都只能看到一个单一的操作顺序. 顺序一致性模型中,所有的操作都必须原子执行且立刻对所有线程可见.**
那顺序一致性模型是怎么实现的了?
理论上,顺序一致性模式有一个全局的内存,这个内存有一个开关,可以连接到任意的线程上(就像我们在物理中学到的单刀开关一样,将开关拨到
左边,左边的灯亮,将开关拨到右边,右边的灯亮). 所以在任意的时间点上,只会有一个线程可以连接到内存,进行读写操作. 当多个线程并发读写
时,按照这么一套逻辑,将会被串行化.
根据上面的定义:
来举一个例子:
假设有线程A和线程B两个线程,线程A中有三个操作,A1,A2,A3
线程B中也有三个操作,B1,B2,B3.
现在假设使用同步的话,先执行A线程,后执行B线程. 那么看到的执行顺序是:
A1->A2->A3->B1->B2->B3
如果不使用同步的话,执行顺序可能是:
A1->B1->A2->B2->A3->B3
虽然整体上无序,但是对于线程A或线程B而言,还是有序的.
注:**这里说的单一操作顺序,不是说还有一种执行情况:
B1->A1->A2->B2->A3->B3
而是说的每个操作对其他线程可见. 换句话说,一个线程对一个变量进行了修改,那么另一个线程可以看到这个修改后的值,这和JMM不同. JMM 中,
如果修改了某个值,不一定对其他线程可见(还没有刷新会主存),所以其他线程是看不到这个线程做的修改(也就是不可见),换句话说,两个线程看到
的操作顺序不是单一的**.
**所以在 JMM 中,如果不小心的话,就会出现内存可见性问题,执行结果会和预期不一致.
因为在 JMM 中,未同步的程序不但整体无序,而且执行顺序也是无序的,而且所欲线程看到的在线顺序也可能不一致(正如上面所说).**
测试用例:
-----------------------------------------------------------
public class SyncTest {
int a=0;
boolean flag = false;
public synchronized void writer(){
a = 1;
flag = true;
}
public synchronized void reader(){
if(flag){
int i = a;
System.out.println("i = " + i);
}
}
@Test
public void test(){
final SyncTest syncTest = new SyncTest();
for(int i=0; i<100; i++){
new Thread(new Runnable() {
public void run() {
syncTest.writer();
}
}).start();
new Thread(new Runnable() {
public void run() {
syncTest.reader();
}
}).start();
}
}
}
发现最终结果是 100 个 1.
从结果中可以得出如下结论:
正确同步的程序,执行结果和程序在顺序一致性模型中的执行结果相同.
是不是有一个疑问:在线程1 中对变量 a 的修改的结果反映到线程2 中了. 这是为什么了?
变量 a 只是一个普通的 int 类型,又不是 volatile 修饰的(强制刷新到主存).
这个就要说到 JMM 内存模型了,线程中的工作内存拿到的是主存中变量的拷贝. 所以线程 1 和线程 2拿到的都是拷贝(实际对象在堆内存中).
所以当在线程1中进行修改的时候,直接返回到堆上了,所以线程2能够可见.
注:**是不是有小伙伴会有上面的疑问?其实不是这样的,为了验证上面的疑惑,我设计了如下程序进行验证:**
public class SyncClass implements Runnable{
static Map<Integer, String> map = new HashMap<Integer, String>();
int count = 10000;
public void run() {
while(true){
if(count > 0){
String str = Thread.currentThread().getName() + " -> " + count;
System.out.println(str);
if(!map.containsKey(count)){
map.put(count, str);
}else{
System.out.println("ERROR: " + count);
}
count = count-1;
}
}
}
}
public class Main {
public static void main(String[] args) {
SyncClass syncClass = new SyncClass();
new Thread(syncClass, "A").start();
new Thread(syncClass, "B").start();
}
}
如果安装上面的理论,那么应该正常输入 1 ~ 10000,且不会重复,但是实际结果是(只选取了部分):
**A -> 10000
B -> 10000**
A -> 9999
B -> 9998
A -> 9997
B -> 9996
A -> 9995
B -> 9994
A -> 9993
B -> 9992
A -> 9991
B -> 9990
A -> 9989
B -> 9988
A -> 9987
B -> 9986
A -> 9985
B -> 9984
A -> 9983
B -> 9982
A -> 9981
A -> 9979
B -> 9980
B -> 9977
B -> 9976
B -> 9975
这就说明上面的理解是错的.
关于上面的疑问,为啥不能实现通讯了?
深入理解 Java 虚拟机中有一句话:**假设线程中访问一个 10M 的对象,也会把这 10M 的内存复制一份拷贝出来吗?事实上并不是如此,这个对象
的引用、对象的中某个在线程访问到的字段是有可能存在拷贝的,但是不会有虚拟机实现成把整个对象拷贝一次**.
对这段话怎么理解了?我是这么认为的:比如说有如下代码:
class B {
String name;
}
class A {
B b;
}
现在在线程1 中修改 b 中的 name 属性,那么我理解的是会加载 A 对象的引用,同时也会加载 B 对象的引用以及 B 对象中的属性值 name.
当线程1 完成修改后,由于 JMM 没有将修改刷新到主存中,所以该操作对其他线程不可见.
如果 JMM 将修改刷新到主存,则其他线程可见.
对于未同步或未正确同步的多线程程序,JMM 只提供最小的安全性. 即线程读取到的值,不会凭空出现(要么是默认值,要么是其他线程写入的值).
还有一点要说明的是,JMM 不保证对 64 位的 long 和 double 类型变量的写入操作具有原子性(针对 32 位机器).
原因:与处理器总线的工作机制有关. 总线是沟通内存和处理器的桥梁. 总线事务分为读事务和写事务. 其实多 CPU 通过总线连接内存和我们上面
讲的单刀双掷开关很像. 当多个 CPU 同时发起总线事务时,总线会通过仲裁,判定那个 CPU 获得访问内存的权利. 而其他处理器则需要等待.
换句话说,在任意时刻,只有一个 CPU 能够访问内存.
在 32 位机器上,如果要保证对 64 位数据类型的写操作具有原子性,开销较大. 所以 Java 语言规范不强求 JVM 对 64 位数据类型的写
操作具有原子性.
当处理器在处理 64 位的写操作,可能会拆分为两个 32 为的写操作. 当一个处理器将高位写入的时候,可能另一个处理器读取到了一个不合法
的数(无效值).
5.volatile 的内存语义
可以这么理解:对 volatile 变量的单个读/写操作看成是使用同一个锁对这些单个读/写操作做了同步.
例如:
class A{
volatile long v1 = 0;
public void set(long l){
v1 = l;
}
public long get(){
return v1;
}
}
等价于:
class B{
long v1 = 0;
public synchronized void set(long l){
v1 = l;
}
public synchronized long get(){
return v1;
}
}
但是有一点需要注意的是:
volatile long v1;
v1++ 操作不具备原子性, 因为 v1++ 是一个复合操作.
volatile 变量的特点:
1.对单个 volatile 变量的读/写操作具有原子性
2.内存可见性,即对 volatile 变量的读,总能看到任意线程对改变的最后写入(修改后会立刻刷新到主存中).
3.禁止指令重排.
注意:线程 A写一个 volatile 变量后,线程B读取同一个 volatile 变量. A 线程在写 volatile 变量之前所有可见的共享变量,在线程B
读取同一个 volatile 变量后,将立即变的对线程B可见.
怎么理解了?
举个例子吧:
class A{
int a = 0;
volatile flag b = false;
public void set(){
a = 1;
flag = true;
}
}
**当线程A执行完这段代码后,JMM 会将 flag = true 及 a = 1 刷新到主存中去, 换句话说在线程 B 读取一个 volatile 变量后,
写线程A在写这个 volatile 变量之前所有课件的共享变量的值都将变得对读线程B可见**.
6.volatile 的内存语义实现
1.当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序.
2.当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序.
3.当第一个操作是 volatile 写时,第二个操作是 volatile 读,不能进行重排序.
为了实现这个语义,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.
1.在每个 volatile 写操作的前面插入 StoreStore 屏障,在其后插入一个 StoreLoad 屏障
2.在每个 volatile 读操作的后面插入 LoadLoad 屏障,再其后插入一个 LoadStore 屏障.
1.**StoreStore 屏障将保证所有的普通写在 volatile 写之前刷新到主内存**.
2.StoreLoad 屏障避免 volatile 写与后面可能有的 volatile 读/写操作重排序.
3.LoadLoad 屏障禁止处理器把上面的 volatile 读和下面的普通读重排序.
4.LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序.
说明:编译器可能根据情况省略掉一些屏障(前提是保证结果是对的).
7.锁的内存语义
注意:**当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新主内存中, 当线程获取锁时,JMM 会把该线程对应的本地内存置为
无效,从而使得被监视器保护区的临界代码必须从主存中读取共享变量**.
8.锁的内存语义实现
Java 中的锁,主要是通过 volatile + cas 来实现的.
加锁:首先读取 volatile state, cas 更新
释放锁:set volatile state.
cas 同时具有 volatile 读和写的内存语义.
**也就是说编译器不能对 cas 和 cas 前后的任意内存操作重排序**.
具体是如何做的了?通过添加 lock 前缀来实现的.
lock 前缀的指令会禁止与之前和之后的读、写指令重排序,同时会将写缓冲区的数据刷新到内存中去.
注:**volatile + cas 是 Java concurrent 包的基石**.
9.final 域的内存语义
对于 final 域,编译器和处理器要遵循两个重排序规则:
1.在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序.
2.初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作不能重排序.
针对第一点说的是,JVM 禁止把 final 域的写重排序到构造函数之外. 编译器会在 final 域的写之后,构造函数 return 之前,插入一个
StoreStore 屏障. 这个屏障禁止处理器把 final 域的写重排序到构造函数之外.
举个例子说明下:
class A {
int a;
final int b;
static A obj;
public A() {
a = 1;
b = 2;
}
public static void set(){
a = new A();
}
public static void get(){
A ref = obj;
int a = ref.a;
int b = ref.b
}
}
线程1调用 set 方法,线程2 调用 get 方法.
站在线程2的角度,可能看到如下情况:
即:**写普通域的操作被编译器重排序到了构造函数之外,那么在线程2看到对象 obj 时,可能 obj 对象还没有构造完成,
那么此时初始值 1 还没有写入 a**.
针对第二点进行说明:编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障.
借用上面的例子,来分析下 get 操作. 一种可能的重排序是:
1. int a = ref.a;
2. A ref = obj;
3. int b = ref.b;
显然 1 是一个非法的读取操作.
final 域为引用类型
对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:
1.在构造器内对一个 final 引用的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋值给一个引用变量,这两个操作间
不能重排序.
如何理解了?
针对上面的语义规则,总结如下:
1.**如果构造器中,有对 final 修饰的变量进行写操作,则该操作一定先于将该对象的引用赋值给另一个引用变量.**
2.**在读取一个对象的 final 域时,一定先读取到包含这个 final 域对象的引用.
还是借用上面的例子,假设线程1调用 set 方法,线程2调用 get 方法. 线程2要么读到空的引用,要么一定是待 final 域初始化完后,obj
才被构造出来.**
为了验证猜想,编写如下代码进行测试.
public class FinalTest {
class A{
final int a;
int b;
private A obj;
public A() {
this.a = 1;
this.b = 2;
}
public void set(){
obj = new A();
}
public void get(){
A ref = obj;
System.out.println("A->" + ref.a);
System.out.println("B->" + ref.b);
}
}
@Test
public void test(){
for(int i=0; i<10000; i++){
final A a = new A();
Thread threadA = new Thread(new Runnable() {
public void run() {
a.set();
}
});
Thread threadB = new Thread(new Runnable() {
public void run() {
a.get();
}
});
threadA.start();
threadB.start();
}
}
}
结果如预期,出现了空指针异常.
为什么 final 引用不能从构造函数内 "溢出" 了?
根据前面我们知道,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量执行的对象的 final 域已经在构造函数中
被正确初始化了. 要的到这个保证,**就需要在构造函数内部,不能让这个被构造的对象的引用为其他线程所见,也就是对象引用不能再构造函数中
"溢出"**.
下面就是一个溢出的例子:
class A {
final int a;
static A obj;
public A(){
i = 1;
**obj = this;**
}
public void set(){
new A();
}
public void get(){
if(null != obj){
int temp = obj.a;
}
}
}
obj = this 这步会是的对象还未完成构造前就为其他线程可见,导致可能在其他线程中无法看到 a 被正确初始化后的值.
final 语义在处理器中的实现
由于处理器的实现不同,所以不同的处理器会依据自身实现,省略掉部分内存屏障. 例如:
x86 处理器中,final 域的读写不会插入任何内存屏障.
总结:**对于 final 域,只要对象是正确构造的(没有溢出),那么不需要同步,就可以保证任意线程都能看到这个 final 域在构造函数中被初始化
后的值**.
10. happens-before
其实只有一条:**只要不改变程序的执行结果,可以随意优化**.
happens-before 规则用于描述两个操作之间的执行顺序. 即:A happens-before B, 则 A 操作的结果对 B 可见,而且第一个操作的执行
顺序排在第二个操作之前. 但是两个操作间存在 happens-before 关系,并不意味着 java 平台的具体实现必须按照 happens-before 来实现.
还是上面的那句话,不管怎么优化,不能改变程序的执行结果.
as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变.
happens-before 规则总结:
1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作.
2.对于一个锁的解锁,happens-before 于随后对该锁的加锁
3.对一个 volatile 变量的写,happens-before 于任意**后续**对这个 volatile 变量的读.
4.A happens-before B, B happens-before C -> A happens-before C.
5.如果线程A执行 ThreadB.start(), 那么线程 A 的 ThreadB.start() happens-before 线程B中的任意操作.(**共享变量对B线程可见**)
6.线程 A 执行 ThreadB.join(), 那么线程B中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功的返回.(**共享变量对A线程可见**)
其实 happens-before 规则,说到的都是共享变量可见性问题. 不管是锁,还是 volatile 变量,亦或是 Thread.start(),
Thread.join() 等,都谈到的是共享变量可见性问题.
11. double check
例如:我们常常会这么写:
class A {
private static Instance instance;
public static Instance getInstance() {
if(null == instance){
instance = new Instance();
}
return instance;
}
}
其实这样写是线程不安全的,当在多线程程序中时,可能两个线程都看到 instance = null, 其中一个线程执行了 instance = new Instance();
后一个线程同样执行了 instance = new Instance();
可以这么修改,是的其线程安全()加锁.
class A {
private static Instance instance;
public synchronized static Instance getInstance() {
if(null == instance){
instance = new Instance();
}
return instance;
}
}
但是加锁会带来性能上的开销,如果被多线程频繁调用的话,这个方法将不能提供令人满意的性能.
所以,后来有了 double-check 这种做法.
class A {
private static Instance instance;
public static Instance getInstance() {
if(null == instance){
synchronized(A.class){
if(null == instance){
instance = new Instance();
}
}
}
return instance;
}
}
似乎这样就非常完美了,但是上面的代码也存在问题. **因为返回的 instance 引用的对象有可能未初始化完成**.
执行 instance = new Instance(); 时可以分为三个步骤
1.分配对象的内存空间(memory = allocate())
2.初始化对象(ctorInstance(memory))
3.收割者 instance 执行刚分配的内存地址(instance = memory)
但是 2 和 3 可能发生重排序,所以其他线程可能看到一个未被初始化的对象.
问题:不是说 synchronized 会在释放锁的时候,将值刷新到主存中去吗?那么其他线程是如何发现 instance 不为空的?
测试如下:
class SyncTest(){
static Person person;
Object object = new Object();
public static void set(){
if(null == person){
synchronized(object){
if(null == person){
person = new Person();
}
}
}
}
}
class A {
@Test
public void test(){
final SyncTest = new SyncTest();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "A").start();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "B").start();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "C").start();
}
}
进过测试发现:当一个线程进入到 synchronized 内实例化 person 时,由于 cpu 时间片用完了,切换到其他 cpu 执行,发现 person 对其他
线程可见了(进入到 synchronized 内的线程还没有执行完).
所以可以得出一个结论:**synchronized 并不是说在释放锁的时候才会将修改的数据刷新到主存**.
针对上面的问题,有两种解决办法:
1. 使用 volatile
class A {
private volatile static Instance instance;
public static Instance getInstance() {
if(null == instance){
synchronized(A.class){
if(null == instance){
instance = new Instance();
}
}
}
return instance;
}
}
本质是**通过禁止 2-3 步重排序来实现线程安全的**.
2.基于类初始化
JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用前),会执行类的初始化. 在执行类的初始化期间,JVM 会去获取一个锁. 这个锁
可以同步多个线程对同一个类的初始化.
class A {
private static Instance instance = new Instance();
public static Instance getInstance() {
return A.instance; // 这里将导致 A 类被初始化.
}
}
关于这二者的区别:
1.如果确实需要对实例字段使用线程安全的延迟初始化,请使用 volatile 方案.
2.如果需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化方案.
12.java 内存模型概述
(1) JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性模型是一个理论参考模型.
(2) Java 内存可见性保证分为 3 类:
1.单线程程序不会出现内存可见性问题
2.正确同步的多线程程序的执行将具有顺序一致性
3.未同步或未正确通同步的多线程程序,JMM 为他们提供最小的安全保证(**要么读到的是默认值,要么是前某个程序写入的值,不会凭空产生**)
共享变量的拷贝. 现在线程 A 需要和线程 B 通讯,则需要通过 A 的本地内存,在 JMM 的控制下,到主内存,
然后从主内存到线程 B 的本地内存,这样就完成了一次通讯.
2.现代编译器为了提高提高性能,会对指令进行重排序. 重排序分为 3 类重排序.
(1)编译器优化的重排序
(2)指令级并行的重排序
(3)内存系统的重排序
因为重排序的问题,我们在编程的时候,是不是会碰到各种莫名其妙的问题?例如 i++,初始时 i=0,但是最终
两个线程的执行结果是不是都是 1?
为了解决可见性问题,Java 有如下策略:
(1)内存屏障
(2)happens-before
1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作
2.对于一个锁的解锁,happens-before 与随后对这个锁的加锁
3.对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读.
4.A happens-before B, B happens-before C, 那么 A happens-before C(传递性).
注意:happens-before 并不意味着一个操作必须先于另一个操作执行,而是说那个操作的结果对后一个操作可见.
对于有数据依赖关系的操作,单线程中是不会进行重排序的,但是在多线程中,数据依赖不被编译器考虑.
有数据依赖关系的:
读后写
写后读
写后写
3.as-if-serial 语义
as-if-serial 语义是说不管怎么重排序,单线程程序的执行结果不能被改变.
4.顺序一致性模型
顺序一致性模式是一个理论的参考模型,它为程序员提供了极强的内存可见性保证. 顺序一致性模型有两大特点:
**(1) 一个线程中的所有操作都是按照顺序的先后顺序执行的.
(2) 不管程序是否同步,所有线程都只能看到一个单一的操作顺序. 顺序一致性模型中,所有的操作都必须原子执行且立刻对所有线程可见.**
那顺序一致性模型是怎么实现的了?
理论上,顺序一致性模式有一个全局的内存,这个内存有一个开关,可以连接到任意的线程上(就像我们在物理中学到的单刀开关一样,将开关拨到
左边,左边的灯亮,将开关拨到右边,右边的灯亮). 所以在任意的时间点上,只会有一个线程可以连接到内存,进行读写操作. 当多个线程并发读写
时,按照这么一套逻辑,将会被串行化.
根据上面的定义:
来举一个例子:
假设有线程A和线程B两个线程,线程A中有三个操作,A1,A2,A3
线程B中也有三个操作,B1,B2,B3.
现在假设使用同步的话,先执行A线程,后执行B线程. 那么看到的执行顺序是:
A1->A2->A3->B1->B2->B3
如果不使用同步的话,执行顺序可能是:
A1->B1->A2->B2->A3->B3
虽然整体上无序,但是对于线程A或线程B而言,还是有序的.
注:**这里说的单一操作顺序,不是说还有一种执行情况:
B1->A1->A2->B2->A3->B3
而是说的每个操作对其他线程可见. 换句话说,一个线程对一个变量进行了修改,那么另一个线程可以看到这个修改后的值,这和JMM不同. JMM 中,
如果修改了某个值,不一定对其他线程可见(还没有刷新会主存),所以其他线程是看不到这个线程做的修改(也就是不可见),换句话说,两个线程看到
的操作顺序不是单一的**.
**所以在 JMM 中,如果不小心的话,就会出现内存可见性问题,执行结果会和预期不一致.
因为在 JMM 中,未同步的程序不但整体无序,而且执行顺序也是无序的,而且所欲线程看到的在线顺序也可能不一致(正如上面所说).**
测试用例:
-----------------------------------------------------------
public class SyncTest {
int a=0;
boolean flag = false;
public synchronized void writer(){
a = 1;
flag = true;
}
public synchronized void reader(){
if(flag){
int i = a;
System.out.println("i = " + i);
}
}
@Test
public void test(){
final SyncTest syncTest = new SyncTest();
for(int i=0; i<100; i++){
new Thread(new Runnable() {
public void run() {
syncTest.writer();
}
}).start();
new Thread(new Runnable() {
public void run() {
syncTest.reader();
}
}).start();
}
}
}
发现最终结果是 100 个 1.
从结果中可以得出如下结论:
正确同步的程序,执行结果和程序在顺序一致性模型中的执行结果相同.
是不是有一个疑问:在线程1 中对变量 a 的修改的结果反映到线程2 中了. 这是为什么了?
变量 a 只是一个普通的 int 类型,又不是 volatile 修饰的(强制刷新到主存).
这个就要说到 JMM 内存模型了,线程中的工作内存拿到的是主存中变量的拷贝. 所以线程 1 和线程 2拿到的都是拷贝(实际对象在堆内存中).
所以当在线程1中进行修改的时候,直接返回到堆上了,所以线程2能够可见.
注:**是不是有小伙伴会有上面的疑问?其实不是这样的,为了验证上面的疑惑,我设计了如下程序进行验证:**
public class SyncClass implements Runnable{
static Map<Integer, String> map = new HashMap<Integer, String>();
int count = 10000;
public void run() {
while(true){
if(count > 0){
String str = Thread.currentThread().getName() + " -> " + count;
System.out.println(str);
if(!map.containsKey(count)){
map.put(count, str);
}else{
System.out.println("ERROR: " + count);
}
count = count-1;
}
}
}
}
public class Main {
public static void main(String[] args) {
SyncClass syncClass = new SyncClass();
new Thread(syncClass, "A").start();
new Thread(syncClass, "B").start();
}
}
如果安装上面的理论,那么应该正常输入 1 ~ 10000,且不会重复,但是实际结果是(只选取了部分):
**A -> 10000
B -> 10000**
A -> 9999
B -> 9998
A -> 9997
B -> 9996
A -> 9995
B -> 9994
A -> 9993
B -> 9992
A -> 9991
B -> 9990
A -> 9989
B -> 9988
A -> 9987
B -> 9986
A -> 9985
B -> 9984
A -> 9983
B -> 9982
A -> 9981
A -> 9979
B -> 9980
B -> 9977
B -> 9976
B -> 9975
这就说明上面的理解是错的.
关于上面的疑问,为啥不能实现通讯了?
深入理解 Java 虚拟机中有一句话:**假设线程中访问一个 10M 的对象,也会把这 10M 的内存复制一份拷贝出来吗?事实上并不是如此,这个对象
的引用、对象的中某个在线程访问到的字段是有可能存在拷贝的,但是不会有虚拟机实现成把整个对象拷贝一次**.
对这段话怎么理解了?我是这么认为的:比如说有如下代码:
class B {
String name;
}
class A {
B b;
}
现在在线程1 中修改 b 中的 name 属性,那么我理解的是会加载 A 对象的引用,同时也会加载 B 对象的引用以及 B 对象中的属性值 name.
当线程1 完成修改后,由于 JMM 没有将修改刷新到主存中,所以该操作对其他线程不可见.
如果 JMM 将修改刷新到主存,则其他线程可见.
对于未同步或未正确同步的多线程程序,JMM 只提供最小的安全性. 即线程读取到的值,不会凭空出现(要么是默认值,要么是其他线程写入的值).
还有一点要说明的是,JMM 不保证对 64 位的 long 和 double 类型变量的写入操作具有原子性(针对 32 位机器).
原因:与处理器总线的工作机制有关. 总线是沟通内存和处理器的桥梁. 总线事务分为读事务和写事务. 其实多 CPU 通过总线连接内存和我们上面
讲的单刀双掷开关很像. 当多个 CPU 同时发起总线事务时,总线会通过仲裁,判定那个 CPU 获得访问内存的权利. 而其他处理器则需要等待.
换句话说,在任意时刻,只有一个 CPU 能够访问内存.
在 32 位机器上,如果要保证对 64 位数据类型的写操作具有原子性,开销较大. 所以 Java 语言规范不强求 JVM 对 64 位数据类型的写
操作具有原子性.
当处理器在处理 64 位的写操作,可能会拆分为两个 32 为的写操作. 当一个处理器将高位写入的时候,可能另一个处理器读取到了一个不合法
的数(无效值).
5.volatile 的内存语义
可以这么理解:对 volatile 变量的单个读/写操作看成是使用同一个锁对这些单个读/写操作做了同步.
例如:
class A{
volatile long v1 = 0;
public void set(long l){
v1 = l;
}
public long get(){
return v1;
}
}
等价于:
class B{
long v1 = 0;
public synchronized void set(long l){
v1 = l;
}
public synchronized long get(){
return v1;
}
}
但是有一点需要注意的是:
volatile long v1;
v1++ 操作不具备原子性, 因为 v1++ 是一个复合操作.
volatile 变量的特点:
1.对单个 volatile 变量的读/写操作具有原子性
2.内存可见性,即对 volatile 变量的读,总能看到任意线程对改变的最后写入(修改后会立刻刷新到主存中).
3.禁止指令重排.
注意:线程 A写一个 volatile 变量后,线程B读取同一个 volatile 变量. A 线程在写 volatile 变量之前所有可见的共享变量,在线程B
读取同一个 volatile 变量后,将立即变的对线程B可见.
怎么理解了?
举个例子吧:
class A{
int a = 0;
volatile flag b = false;
public void set(){
a = 1;
flag = true;
}
}
**当线程A执行完这段代码后,JMM 会将 flag = true 及 a = 1 刷新到主存中去, 换句话说在线程 B 读取一个 volatile 变量后,
写线程A在写这个 volatile 变量之前所有课件的共享变量的值都将变得对读线程B可见**.
6.volatile 的内存语义实现
1.当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序.
2.当第一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序.
3.当第一个操作是 volatile 写时,第二个操作是 volatile 读,不能进行重排序.
为了实现这个语义,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序.
1.在每个 volatile 写操作的前面插入 StoreStore 屏障,在其后插入一个 StoreLoad 屏障
2.在每个 volatile 读操作的后面插入 LoadLoad 屏障,再其后插入一个 LoadStore 屏障.
1.**StoreStore 屏障将保证所有的普通写在 volatile 写之前刷新到主内存**.
2.StoreLoad 屏障避免 volatile 写与后面可能有的 volatile 读/写操作重排序.
3.LoadLoad 屏障禁止处理器把上面的 volatile 读和下面的普通读重排序.
4.LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序.
说明:编译器可能根据情况省略掉一些屏障(前提是保证结果是对的).
7.锁的内存语义
注意:**当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新主内存中, 当线程获取锁时,JMM 会把该线程对应的本地内存置为
无效,从而使得被监视器保护区的临界代码必须从主存中读取共享变量**.
8.锁的内存语义实现
Java 中的锁,主要是通过 volatile + cas 来实现的.
加锁:首先读取 volatile state, cas 更新
释放锁:set volatile state.
cas 同时具有 volatile 读和写的内存语义.
**也就是说编译器不能对 cas 和 cas 前后的任意内存操作重排序**.
具体是如何做的了?通过添加 lock 前缀来实现的.
lock 前缀的指令会禁止与之前和之后的读、写指令重排序,同时会将写缓冲区的数据刷新到内存中去.
注:**volatile + cas 是 Java concurrent 包的基石**.
9.final 域的内存语义
对于 final 域,编译器和处理器要遵循两个重排序规则:
1.在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序.
2.初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作不能重排序.
针对第一点说的是,JVM 禁止把 final 域的写重排序到构造函数之外. 编译器会在 final 域的写之后,构造函数 return 之前,插入一个
StoreStore 屏障. 这个屏障禁止处理器把 final 域的写重排序到构造函数之外.
举个例子说明下:
class A {
int a;
final int b;
static A obj;
public A() {
a = 1;
b = 2;
}
public static void set(){
a = new A();
}
public static void get(){
A ref = obj;
int a = ref.a;
int b = ref.b
}
}
线程1调用 set 方法,线程2 调用 get 方法.
站在线程2的角度,可能看到如下情况:
即:**写普通域的操作被编译器重排序到了构造函数之外,那么在线程2看到对象 obj 时,可能 obj 对象还没有构造完成,
那么此时初始值 1 还没有写入 a**.
针对第二点进行说明:编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障.
借用上面的例子,来分析下 get 操作. 一种可能的重排序是:
1. int a = ref.a;
2. A ref = obj;
3. int b = ref.b;
显然 1 是一个非法的读取操作.
final 域为引用类型
对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:
1.在构造器内对一个 final 引用的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋值给一个引用变量,这两个操作间
不能重排序.
如何理解了?
针对上面的语义规则,总结如下:
1.**如果构造器中,有对 final 修饰的变量进行写操作,则该操作一定先于将该对象的引用赋值给另一个引用变量.**
2.**在读取一个对象的 final 域时,一定先读取到包含这个 final 域对象的引用.
还是借用上面的例子,假设线程1调用 set 方法,线程2调用 get 方法. 线程2要么读到空的引用,要么一定是待 final 域初始化完后,obj
才被构造出来.**
为了验证猜想,编写如下代码进行测试.
public class FinalTest {
class A{
final int a;
int b;
private A obj;
public A() {
this.a = 1;
this.b = 2;
}
public void set(){
obj = new A();
}
public void get(){
A ref = obj;
System.out.println("A->" + ref.a);
System.out.println("B->" + ref.b);
}
}
@Test
public void test(){
for(int i=0; i<10000; i++){
final A a = new A();
Thread threadA = new Thread(new Runnable() {
public void run() {
a.set();
}
});
Thread threadB = new Thread(new Runnable() {
public void run() {
a.get();
}
});
threadA.start();
threadB.start();
}
}
}
结果如预期,出现了空指针异常.
为什么 final 引用不能从构造函数内 "溢出" 了?
根据前面我们知道,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量执行的对象的 final 域已经在构造函数中
被正确初始化了. 要的到这个保证,**就需要在构造函数内部,不能让这个被构造的对象的引用为其他线程所见,也就是对象引用不能再构造函数中
"溢出"**.
下面就是一个溢出的例子:
class A {
final int a;
static A obj;
public A(){
i = 1;
**obj = this;**
}
public void set(){
new A();
}
public void get(){
if(null != obj){
int temp = obj.a;
}
}
}
obj = this 这步会是的对象还未完成构造前就为其他线程可见,导致可能在其他线程中无法看到 a 被正确初始化后的值.
final 语义在处理器中的实现
由于处理器的实现不同,所以不同的处理器会依据自身实现,省略掉部分内存屏障. 例如:
x86 处理器中,final 域的读写不会插入任何内存屏障.
总结:**对于 final 域,只要对象是正确构造的(没有溢出),那么不需要同步,就可以保证任意线程都能看到这个 final 域在构造函数中被初始化
后的值**.
10. happens-before
其实只有一条:**只要不改变程序的执行结果,可以随意优化**.
happens-before 规则用于描述两个操作之间的执行顺序. 即:A happens-before B, 则 A 操作的结果对 B 可见,而且第一个操作的执行
顺序排在第二个操作之前. 但是两个操作间存在 happens-before 关系,并不意味着 java 平台的具体实现必须按照 happens-before 来实现.
还是上面的那句话,不管怎么优化,不能改变程序的执行结果.
as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变.
happens-before 规则总结:
1.一个线程中的每个操作,happens-before 于该线程中的任意后续操作.
2.对于一个锁的解锁,happens-before 于随后对该锁的加锁
3.对一个 volatile 变量的写,happens-before 于任意**后续**对这个 volatile 变量的读.
4.A happens-before B, B happens-before C -> A happens-before C.
5.如果线程A执行 ThreadB.start(), 那么线程 A 的 ThreadB.start() happens-before 线程B中的任意操作.(**共享变量对B线程可见**)
6.线程 A 执行 ThreadB.join(), 那么线程B中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功的返回.(**共享变量对A线程可见**)
其实 happens-before 规则,说到的都是共享变量可见性问题. 不管是锁,还是 volatile 变量,亦或是 Thread.start(),
Thread.join() 等,都谈到的是共享变量可见性问题.
11. double check
例如:我们常常会这么写:
class A {
private static Instance instance;
public static Instance getInstance() {
if(null == instance){
instance = new Instance();
}
return instance;
}
}
其实这样写是线程不安全的,当在多线程程序中时,可能两个线程都看到 instance = null, 其中一个线程执行了 instance = new Instance();
后一个线程同样执行了 instance = new Instance();
可以这么修改,是的其线程安全()加锁.
class A {
private static Instance instance;
public synchronized static Instance getInstance() {
if(null == instance){
instance = new Instance();
}
return instance;
}
}
但是加锁会带来性能上的开销,如果被多线程频繁调用的话,这个方法将不能提供令人满意的性能.
所以,后来有了 double-check 这种做法.
class A {
private static Instance instance;
public static Instance getInstance() {
if(null == instance){
synchronized(A.class){
if(null == instance){
instance = new Instance();
}
}
}
return instance;
}
}
似乎这样就非常完美了,但是上面的代码也存在问题. **因为返回的 instance 引用的对象有可能未初始化完成**.
执行 instance = new Instance(); 时可以分为三个步骤
1.分配对象的内存空间(memory = allocate())
2.初始化对象(ctorInstance(memory))
3.收割者 instance 执行刚分配的内存地址(instance = memory)
但是 2 和 3 可能发生重排序,所以其他线程可能看到一个未被初始化的对象.
问题:不是说 synchronized 会在释放锁的时候,将值刷新到主存中去吗?那么其他线程是如何发现 instance 不为空的?
测试如下:
class SyncTest(){
static Person person;
Object object = new Object();
public static void set(){
if(null == person){
synchronized(object){
if(null == person){
person = new Person();
}
}
}
}
}
class A {
@Test
public void test(){
final SyncTest = new SyncTest();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "A").start();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "B").start();
new Thread(new Runnable(){
public void run(){
syncTest.set();
}
}, "C").start();
}
}
进过测试发现:当一个线程进入到 synchronized 内实例化 person 时,由于 cpu 时间片用完了,切换到其他 cpu 执行,发现 person 对其他
线程可见了(进入到 synchronized 内的线程还没有执行完).
所以可以得出一个结论:**synchronized 并不是说在释放锁的时候才会将修改的数据刷新到主存**.
针对上面的问题,有两种解决办法:
1. 使用 volatile
class A {
private volatile static Instance instance;
public static Instance getInstance() {
if(null == instance){
synchronized(A.class){
if(null == instance){
instance = new Instance();
}
}
}
return instance;
}
}
本质是**通过禁止 2-3 步重排序来实现线程安全的**.
2.基于类初始化
JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用前),会执行类的初始化. 在执行类的初始化期间,JVM 会去获取一个锁. 这个锁
可以同步多个线程对同一个类的初始化.
class A {
private static Instance instance = new Instance();
public static Instance getInstance() {
return A.instance; // 这里将导致 A 类被初始化.
}
}
关于这二者的区别:
1.如果确实需要对实例字段使用线程安全的延迟初始化,请使用 volatile 方案.
2.如果需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化方案.
12.java 内存模型概述
(1) JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性模型是一个理论参考模型.
(2) Java 内存可见性保证分为 3 类:
1.单线程程序不会出现内存可见性问题
2.正确同步的多线程程序的执行将具有顺序一致性
3.未同步或未正确通同步的多线程程序,JMM 为他们提供最小的安全保证(**要么读到的是默认值,要么是前某个程序写入的值,不会凭空产生**)
发表评论
-
Executor 框架
2019-11-05 22:44 534说明:本篇文章是在阅 ... -
CountDownLatch、CyclicBarrier 和 Semaphore原理分析
2019-11-03 10:46 467Java 中常用的并发工具有 CountDownLatch、C ... -
Java中的13个原子操作类
2019-11-02 23:40 584说明:本篇文章是在阅读《Java 并发编程艺术》过程中的一些笔 ... -
Java并发容器和框架
2019-11-02 18:25 434说明:本篇文章是在阅读《Java 并发编程艺术》过程中的一些笔 ... -
Java中的锁
2019-10-20 22:54 532说明:本篇文章是在阅读《Java 并发编程艺术》过程中的一些笔 ... -
Java并发编程基础
2019-10-19 21:42 506说明:本篇文章是在阅 ... -
Java 并发编程艺术阅读笔记
2019-10-08 20:38 677有关 Java 并发编程艺术的阅读笔记可以去我的 github ...
相关推荐
Java内存模型的深入分析对于编写高性能的Java应用程序至关重要,本文将详细探讨Java内存模型的组成部分及其在编程中的实际应用。 Java内存模型主要由以下几个部分构成: 1. 程序计数器(Program Counter Register...
Java程序员了解CPU以及相关的内存模型,对于深入理解...通过分析具体的编程问题,比如Java锁的不同实现方式、CPU缓存的工作机制等,可以帮助程序员更好地理解Java内存模型,在多线程环境下写出更加健壮和高效的代码。
深入理解Java内存模型,不仅能够帮助我们编写出高效、线程安全的代码,还能在面临并发问题时提供有力的分析和解决手段。通过阅读《深入理解Java内存模型》这本书,开发者可以进一步掌握Java并发编程的核心技术,提升...
Java内存模型(Java Memory Model,简称JMM)是Java平台中非常重要的一部分,它定义了线程如何访问共享数据以及如何确保这些数据的可见性、原子性和有序性。JMM的存在是为了处理多线程环境下的数据一致性问题,防止...
阿里巴巴专家讲座——java内存模型与并发技术。 主要内容: 学习java并发理论基础:Java Memory Model 学习java并发技术基础:理解同步是如何工作 分析程序什么时候需要同步 几个典型的并发设计策略
在《深入理解Java内存模型(经典)》这本书中,作者可能详细探讨了JMM的原理、规则以及在实际编程中的应用,包括案例分析和最佳实践。通过阅读这本书,开发者可以更深入地掌握Java并发编程的核心技术,提高程序的...
Java内存模型是Java程序运行的基础,它规定了Java中数据在内存中的存储和访问规则。在Java内存模型中,堆内存和栈内存是两个核心概念。堆内存主要存放对象的实例,所有通过new创建的对象都会在堆内存中分配空间。而...
《深入Java内存模型》是一本面向Java开发人员的专业书籍,旨在帮助读者深入理解Java平台的内存管理和性能优化。这本书详细探讨了Java内存模型(JVM)的基础知识,以及如何利用这些知识来提升程序的效率和稳定性。...
本文将深入探讨 JMM 的设计原理和实现机理,通过结合实际的代码实验和分析,帮助读者更好地理解 Java 内存模型。 1. 理解“规范”与“实现” Java 内存模型的设计是基于 Java 语言规范(Java Language ...
### Java内存模型(JMM)详解 #### 1. JMM简介 Java内存模型(Java Memory Model,简称JMM)是Java虚拟机规范中定义的一套规定,用来描述Java程序中的各种变量(包括实例字段、静态字段和数组元素)在并发环境下的...
首先,我们需要了解Java内存模型的基础。Java内存主要分为三个区域:堆(Heap)、栈(Stack)和方法区(Method Area)。堆用于存储对象实例,栈用于存储方法调用及局部变量,而方法区则存储类信息、常量、静态变量等...
Java内存模型(JVM Memory Model,简称JMM)是Java平台中的一个重要概念,它定义了在多线程环境下,如何在共享内存中读写变量的行为。JMM的主要目标是确保多线程环境下的可见性、有序性和原子性,从而避免数据不一致...
本文将深入探讨`concurrentMap`在Java内存模型(JMM,Java Memory Model)中的实现原理,以及如何通过HotCode优化并发性能。 Java内存模型定义了线程之间的共享变量访问规则,确保在多线程环境下正确地同步数据。...
- 理解Java内存模型(堆、栈、方法区等)对使用内存分析工具至关重要。 总之,HeapAnalyzer是Java开发者处理内存溢出问题的强大助手。通过熟练掌握其使用,我们可以有效地定位并解决内存问题,提升应用的稳定性和...
Java内存模型(JMM,Java Memory Model)是Java并发编程中的关键概念,它定义了程序中各个变量(包括实例域、静态域和数组元素)之间的关系,以及它们在实际计算机系统中的存储和读取方式。相比C和C++,Java具有一个...
通过以上对JVM内存模型和垃圾收集策略的分析,可以看出JVM内存管理的复杂性以及垃圾收集机制的重要性。了解和掌握这些知识点,对于开发高性能Java应用是非常有帮助的。在实际开发过程中,合理配置JVM参数、选择合适...
Java内存模型,简称JMM(Java Memory Model),是Java虚拟机规范中定义的一种抽象概念,它规定了如何在多线程环境下正确地处理共享变量的读写操作,以确保程序的正确性和可见性。深入理解Java内存模型对于进行高效的...
分析和解决这些问题需要深入理解Java内存模型、垃圾收集机制以及JVM优化策略。以下是对这个主题的详细阐述: 1. **Java内存模型** Java内存主要分为堆内存(Heap)、栈内存(Stack)和方法区(Method Area)。堆...
IBM内存分析工具,作为一个专业的Java内存诊断工具,专门针对Java内存溢出(Memory Overflow)和内存泄露(Memory Leak)问题进行深度分析,帮助开发者定位并解决这些问题。本文将详细介绍IBM内存分析工具的功能、...
首先,了解Java内存模型至关重要。Java程序运行时主要涉及四种内存区域:程序计数器、虚拟机栈、本地方法栈、堆和方法区(在Java 8及以后版本中,方法区被元空间取代)。 1. **程序计数器**:每个线程都有一个独立...