Java并发编程艺术学习笔记(二)

Java并发编程艺术学习笔记(二)

Java内存模型

在《深入理解JVM》中已经学习了一些关于java内存模型的知识,在并发编程中关于java内存模型写的更详细,再做次总结有助于理解深刻。

一.Java内存模型基础

Ⅰ.并发编程模型的两个关键问题

两个关键问题是线程之间如何通信以及线程之间如何同步。
在共享内存的并发模型中,线程之间共享程序的公共状态,通过修改公共状态来隐式通信。
在消息传递的并发模型中,线程之间没有公共状态,线程之间通过发送消息来显式地进行通信。
同步是控制不同线程间操作发生相对顺序的机制,共享内存模型中同步是显式进行的,必须显式制定某个方法或者某段代码需要在线程之间互斥执行。在消息传递的并发模型中,由于消息发送必须在消息的接收之前,因此同步是隐形的。
Java并发采用的是共享内存的并发模型。

Ⅱ.Java内存模型的抽象结构

JVM中栈帧是线程独有的,不会有内存可见性问题,而Java堆是线程共享的,因此会存在线程可见性问题。
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,而每个线程都有私有的本地内存,其中存储着线程读/写共享变量的副本。
线程A与线程B之间要通信的话,需要经历下面2个步骤:
①线程A把本地内存A中更新过的共享变量更新到主内存中。
②线程B到主内存中读取线程A之前已经更新过的共享变量。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

Ⅲ.从源代码到指令序列的重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序,分成以下三种:
①编译器优化的重排序。
②指令级并行的重排序。现在处理器采用指令级并行技术将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应的机器指令的执行顺序。
③内存系统的重排序。处理器使用缓存和读/写缓冲区,使得加载和存储看起来可能是乱序执行。
2和3都属于处理器的重排序。这些重排序都会造成多线程程序出现内存可见性问题,对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器重排序,JMM会要求Java编译器在生成指令序列时,插入特定类型的内存屏障,通过内存屏障可以禁止特定类型的处理器重排序。

Ⅳ.并发编程模型的分类

处理器一般需要写缓冲区来保证指令流水线可以持续运行,但需要注意的是每个处理器的写缓冲区只可以对这个处理器可见,这会产生严重的后果:处理器对于内存的读/写操作顺序不一定与内存实际发生的读/写操作一致。处理器写操作认为写入缓冲区就完成了,但是对于内存来说只有从缓冲区写入内存中才算完成。
由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对于写-读操作进行重排序。
为了保证内存可见性,Java编译器会在合适的位置插入内存屏障来禁止默写特定类型的处理器重排序。
JMM把内存屏障分成四类,如下图:
在这里插入图片描述
写读屏障是个“全能型”屏障,但是开销很大。

Ⅴ.happens-before简介

JDK5以后,JSR-133内存模型被采用,采用了全新的happens-before来阐述内存可见性,如果一个操作执行的结果需要对另一个操作可见,那么两个操作必须存在happens-before关系。
与程序员密切相关的一些happens-before规则:
①程序顺序规则:一个线程的每个操作before于后面的任何操作。
②监视器锁规则:对一个锁的解锁一定before与随后对这个锁的加锁。
③volatile变量规则:对一个volatile的写一定before后面任何对于volatile的读。
④传递性:AbeforeB,BbeforeC,那么A一定beforeC。
**happens-before不意味着前一个操作必须在后一个操作前运行,而仅仅要求前一个的结果对后一个操作可见。
happens-before的这四个定义意味着java程序员不需要为了这四个基础规则再去学习重排序规则,JMM实现默认了这四条基本规则,但是如果还要再加规则,就需要程序员额外添加。

二.重排序

重排序是指编译器和处理器为了优化性能而对指令序列重新排序的一种手段。

Ⅰ.数据依赖性

数据依赖性仅仅考虑了单个处理器执行的指令序列和单个线程执行的操作。而线程间的是不被考虑的。
主要有三种:写后读,写后写,读后写

Ⅱ.as-if-serial语义

这种语义把单线程程序保护了起来,保证了结果的准确性。

Ⅲ.程序顺序规则

A happens-before B,如果操作A的执行结果并不需要对于B可见,而且重排序之后的执行结果跟之前一样,那么JMM会认为这种重排序并不非法。

Ⅳ.重排序对于多线程的影响

重排序可能改变程序的执行结果。

三.顺序一致性

顺序一致性内存模型是一个理论参考模型,设计的时候,处理器的内存模型和编程语言内存模型都会以顺序一致性内存模型作为参考。

Ⅰ.数据竞争与顺序一致性

程序未正确同步时,可能会存在线程竞争。如果程序是正确同步的,程序的执行将具有顺序一致性----即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

Ⅱ.顺序一致性内存模型

模型是一个被理想化的模型,给程序员提供了极强的内存可见性保证,顺序一致性内存模型有两大特征:
(1)一个线程中的所有操作必须按程序的顺序来执行。
(2)(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序,在顺序一致性内存模型中,每个操作都必须原子执行并且立刻对所有线程可见。
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。但是在JMM中未同步不但整体是无序的,所有线程自己也是无序的。

Ⅲ.同步程序的顺序一致性效果

同步程序可以保证线程间的顺序关系,但是仍然不能保证临界区中代码的顺序执行,这给了编译器和处理器的优化提供了便利的条件。

Ⅳ.未同步程序的执行特性

对于未同步的程序,JMM只提供了最小的安全性:线程执行时读取到的值要么是之前某个线程写入的值,要么是默认值。未同步程序在JMM执行时,整体上是无序的,执行结果无法预知,未同步程序在两个模型执行特性有如下几个差异:
(1)顺序一致性模型保证了单个线程中是顺序执行的,而JMM中不保证。
(2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM中不保证。
(3)JMM中不保证对于64位long和double变量的写操作具有原子性,而顺序一致性模型保证了。
从JSR-133内存模型(JDK5)开始,仅仅把一个long/double写操作拆分成两个32位的写操作来执行,任意的读操作必须具有原子性。

四.volatile的内存语义

Ⅰ.volatile的特性

volatile的特性可以理解成对于volatile变量的读写看做是同一个锁对于这些单个读写操作进行了同步。
1.可见性
2.原子性,单个变量具有原子性,但是对于v++这种复合变量没有原子性。

Ⅱ.volatile写-读建立的happens-before关系

从JSR-133开始,volatile的写读可以实现线程之间的通信。从内存语义来看,volatile的写读与锁的释放-获取有同样的效果。
所以volatile变量规则相当于就是监听器锁规则。

Ⅲ.volatile写-读的内存语义

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量更新回主内存。
当读一个volatile变量时,JMM会把该线程对应的本地内存设为无效,线程接下来将从主内存中读取共享变量。
**个人理解:volatile读=工作内存更新+普通读
volatile写=普通写+主内存更新

Ⅳ.volatile内存语义的实现

JMM针对编译器制定了volatile重排序规则表:
在这里插入图片描述
(1)第二个操作是volatile写时,无论第一个操作是什么,都不能重排序。
(2)当第一个操作是volatile读时,无论第二个操作是什么,都不能重排序。
(3)第一个操作是volatile写时,第二个操作是volatile读时也不能重排序。
基于保守策略JMM内存屏障插入策略:
(1)每个volatile写操作前插入一个StoreStore屏障。
(2)每个volatile写操作后插入一个StoreLoad屏障。
(3)每个volatile读操作后插入一个LoadStore屏障。
(4)每个volatile读操作后插入一个LoadLoad屏障。
编译器在生成字节码的时候可能会做些优化,省去一些不必要的内存屏障。

Ⅴ.JSR-133为什么要增强volatile的内存语义

因为JSR-133之前并没有禁止volatile变量和普通变量的重排序,因此会出现上述(1)(2)的问题。
volatile只能保证单个volatile变量的读/写原子性,而锁能保证整个临界区的原子性,因此锁更强大,但是消耗也更大。

五.锁的内存含义

Ⅰ.锁的释放-获取建立的happens-before关系

锁除了可以让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
锁用到的happens-before关系是上述的监听器锁关系,及上一个锁的释放happens-before下一次锁的加锁。

Ⅱ.锁的释放和获取的内存语义

线程获取锁时,JMM会把线程对应的本地内存设为无效,必须从主内存中读取共享变量。
线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
获取锁和释放锁的内存语义跟volatile相同。

Ⅲ.锁内存语义的实现

借助ReentrantLock源代码来分析内存语义的实现机制。
ReentrantLock分为公平锁和非公平锁,公平锁加锁lock():
(1)ReentrantLock:lock();
(2)FairSync:lock();
(3)AbstractQueuedSynchronizer:acquire(int arg);
(4)ReentrantLock:tryAcquire(int acquires)。
第四步才是真正的加锁,源码是:

        final boolean TryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

加锁的过程首先会获取state值,如果是0说明没有现成获得锁就会执行加锁操作,如果>0说明已经有线程占据锁,那么根据重入锁的规则,如果加锁的线程就是当前线程,就会状态值加1,如果不是就会阻塞。
解锁方法unlock()调用轨迹如下:
(1)ReentrantLock:unlock()
(2)AbstractQueuedSynchronize:release(int args)
(3)Sync:tryRelease(int releases)
真正解锁的是第三步,源代码是:

        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

首先获取当前锁的state值,与解锁次数相减得到局部变量c,如果发送解锁指令的线程不是当前拥有的锁的线程就会抛出错误,如果c=0,意味着解锁操作后该对象将没有锁,这时候将拥有该锁的对象设为null,如果c!=0,就把state设为c。
需要注意的时候加锁解锁的过程中,都是通过state变量来判断当前对象是否加锁,而state使用volatile修饰的保证了内存的可见性。
非公平锁和公平锁解锁过程的内存语义完全一样,而非公平锁的获取过程有所不同,加锁方法lock()调用轨迹:
(1)ReentrantLock:lock()。
(2)NonfairSync:lock()。
(3)AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
第三步是真正加锁过程,源码是:

        final void lock() {
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

该方法用原子操作CAS来更新state变量,先从编译器和处理器的角度来分析下CAS如何同时具有volatile读和volatile写的内存语义。
编译器和处理器不会对volatile读以后做任何重排序,也不会volatile写前面的做任何重排序,那么CAS就需要不能对CAS前面以及CAS后面做任何的重排序。
程序会根据当前处理器的类型来判断是否为cmpxchg指令添加lock前缀(Lock Cmpxchg)。如果是多处理器运行的,就需要加上lock前缀,单处理器就不需要lock前缀。
lock的几点说明:
(1)确保了对内存读-改-写都具有原子执行。在之前主要通过总线锁定,现在有内存锁定来减少执行开销。
(2)禁止该指令与前后指令重排序。
(3)把写缓冲区的所有数据同步到内存中。
第二三点具有的内存屏障效果足够同时实现volatile读和volatile写的内存语义。
公平锁和非公平锁的内存语义总结:
(1)公平锁和非公平锁释放都需要写一个volatile变量state。
(2)公平锁获得时,首先会去读volatile变量。
(3)非公平锁获得时,首先会用CAS去更新volatile变量。
锁的获得与释放实现至少有两种方式:
(1)volatile变量的写和读的内存语义。
(2)CAS附带的volatile读和volatile写的内存语义。

Ⅳ.concurrent包的实现

java线程之间的通信有了下面四种方式:
(1)A写volatile变量,B读volatile变量。
(2)A写volatile变量,BCAS这个变量。
(3)ACAS变量,BCAS变量。
(4)ACAS变量,B读volatile变量。
concurrent包中有一种通用的实现方法:
(1)声明共享变量为volatile。
(2)CAS更新来实现线程之间的同步。
(3)配合使用volatile的特性和CAS的特性来实现线程之间的通信。

六.final域的内存语义

final域的读和写更像是普通变量的访问。

Ⅰ.final域的重排序规则

final域中编译器和处理器需要遵守两个重排序规则:
(1)构造函数中对于final域的写入和随后被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。
(2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作不能重排序。

Ⅱ.写final域的重排序规则

写final域的重排序规则禁止把final域的写重排序到构造函数之外,实现包括两个方面:
(1)JMM禁止编译器把final域的写重排序到构造函数之外。
(2)编译器会在final域的写之后,构造函数return之前,插入一个storestore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。
写final域的重排序规则保证了在对象引用为任意线程可见前,对象的final已经被正确初始化过了。

Ⅲ.读final域的重排序规则

读final域的重排序规则是在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止了处理器重排序这两个操作。
编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则可以保证:在读一个对象的final域之前,一定先读包含这个final域的对象的引用。

Ⅳ.final域为引用类型

对于引用类型,写final域的重排序规则堆编译器和处理器加了如下的约束:
在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。

Ⅴ.为什么final引用不能从构造函数中“溢出”

在构造函数内部,不能让这个被构造对象的引用为其他线程所见。即如果想要final引用一定是在对象被引用时已经构造完成,就不能在构造函数中使用obj=this这样的指令使得其溢出。

Ⅵ.final语义在处理器中的实现

由于有些处理器并不会对一些操作进行重排,例如X86不会对写-写操作进行重排,也不会对存在间接依赖关系的操作重排序,因此final域在X86中的读写实际上不需要插入任何内存屏障。

Ⅶ.JSR-133为什么要增强final的语义

因为旧的java内存模型中严重缺陷就是可能看到final修饰的值会改变。增强后确保了只要对象是安全构建的并且其中没有对象溢出,那么可以不用锁也可以保证final在构造函数中已经初始化。

七.happens-before

对于程序员来说,理解happens-before是理解JMM的关键。

Ⅰ.JMM的设计

设计JMM时候,需要考虑的两个关键因素:
(1)程序员对于内存模型的使用。程序员希望内存模型易于理解、编程,甚至希望一个强内存模型来编写代码。
(2)编译器和处理器对内存模型的实现。编译器和处理器希望内存模型的约束越少越好,可以增加更多的优化。
JMM对于happens-before要求禁止的重排序分成了下面两类:
(1)会改变程序执行结果的重排序。(JMM要求编译器和处理器必须禁止这种重排序)
(2)不会改变程序执行结果的重排序。(JMM允许这种重排序)
也可以得出两点结论:
(1)JMM向程序员提出的happens-before规则简单易于,确保了足够强的内存可见性。但是有些内存可见性的保证并不一定存在。
(2)JMM对编译器和处理器的束缚已经尽可能少。

Ⅱ.happens-before的定义

JSR-133使用happens-before的概念来指定两个操作之间的执行顺序。给程序员提供了跨线程的内存可见性保证。具体定义如下:
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须按照关系指定的顺序进行,如果重排序后的执行结果不改变,那么也是允许的。
(1)是对程序员的承诺,(2)是JMM对编译器和处理器重排序的约束原则。
因此happens-before关系本质上和as-if-serial语义是一回事。
(1)as-if-serial保证了单线程结果不被改变,而happens-before保证了正确同步的多线程程序执行结果不被改变。
(2)as-if-serial给程序员带来了幻境:单线程都是按照程序的顺序来执行的;happens-before给程序员带来了幻境:正确同步的多线程都是按照happens-before顺序来完成的。

Ⅲ.happens-before规则

前面已经讲到了4个规则,这里再总结一下:
(1)程序顺序规则
(2)监视器锁规则
(3)volatile变量规则
(4)传递性
(5)start()规则:如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()操作happens-before线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

八.双重检查锁定与延迟初始化

java多线程程序当中,需要采用延迟初始化来降低初始化类和创建对象的开销。双重锁定检查是常见的延迟初始化技术,但是这是一个错误的用法。接下来分析双重检查锁定为什么错误,以及两种线程安全的延迟初始化方案。

Ⅰ.双重检查锁定的由来
public class DoubleCheckedLocking {
    private static Instance instance;
    public static Instance getInstance(){
        if (instance==null){
            synchronized (DoubleCheckedLocking.class){
                if (instance==null){
                    instance=new Instance();
                }
            }
        }
        return instance;
    }
}

表面上看起来很完美:多线程状态时,在对象还没有创始前,多个线程会通过第一个检查,在第二次检查的时候只要给第一个线程创建新对象,剩下的线程都会通不过第二个线程而返回。
但是问题出在代码读取第一次检查instance!=null时,instance引用的对象可能还没有完成初始化。

Ⅱ.问题的根源

问题的根源出在instance=new Instance()中:在编译器中这会转成3个伪代码
(1)memory=allocate();/分配对象的内存空间
(2)ctorInstance(memory);/初始化对象
(3)instance=memory;/设置instance指向刚分配的内存地址
而2和3可能被重排序,因此可能出现instance!=null而对象却没有初始化。
因此可以由两个办法来实现线程安全的延迟初始化:
(1)不允许2,3重排序。
(2)允许2,3重排序,但是不允许其他线程看到这个重排序。

Ⅲ.基于volatile的解决方案

可以将instance声明成volatile类型,就可以实现线程安全的进行类初始化。
本质上就是禁止了2和3之间的重排序。

Ⅳ.基于类初始化的解决方案

JVM在类的初始化阶段(Class被加载,在被线程使用之前),会执行类的初始化,而类的初始化阶段JVM会自动给与一个锁,这个锁可以同步多个线程对于一个类的初始化。
因此可以通过以下代码来实现:

public class InstanceFactory {
    private static class InstanceHolder{
        public static Instance instance=new Instance();
    }
    private static Instance getInstance(){
        return InstanceHolder.instance;
    }
}

这个方案的本质是:允许2,3重排序,但是其他线程看不到。
在JVM规范中,只要以下的任意一种情况发生,那么这个类或者接口类型T将被立刻初始化:
(1)T是一个类,而且T类型的实例被创建。
(2)T是一个类,并且T中声明的一个静态方法被调用。
(3)T中声明的一个静态字段被赋值。
(4)T中声明的一个静态字段被使用,并且这个字段不是一个常量字段。
(5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
因此首先执行getInstance()方法的线程将导致InstanceHolder类被初始化。
java语言规范规定,对于每一个类或者接口C,都有一个唯一的初始化锁LC与之对应。

九.Java内存模型综述

Ⅰ.处理器的内存模型

顺序一致性模型是一个理论参考模型,JMM和处理器的内存模型都会以顺序一致性模型作为参考,但是与顺序一致性模型完全一致,那么很多编译器和处理器优化都会被放弃,对性能影响很大。
根据不同类型的读/写操作组合的执行顺序的放松,可以将常见处理器的内存模型划分为以下几种类型:
(1)放松写-读操作的顺序,由此产生了Total Store Ordering内存模型(TSO)。
(2)放松写-写操作的顺序,由此产生了Partial Store Order内存模型(PSO)。
(3)在上述两条基础上继续放松程序中的读-写以及读-读操作,由此产生了Relaxed Memory Order内存模型(RMO)和PowerPC内存模型。
这里处理器对于读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的。
JMM屏蔽了不同处理器内存模型的差异,他在不同的处理器平台之上为JAVA程序员呈现了一个一致的内存模型。

Ⅱ.各种内存模型之间的关系

JMM是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。
可以得知处理器内存模型一般都比语言内存模型要弱。

Ⅲ.JMM的内存可见性保证

Java程序的内存可见性保证可以分为下列3类:
(1)单线程程序,单线程程序肯定不会出现内存可见性问题。
(2)正确同步的多线程程序。JMM通过限制编译器和处理器的重排序来为程序员提供内存可见性的保证。
(3)未同步/未正确同步的多线程程序。JMM为它们提供了最小的安全性保障:线程执行时读到的要么是某个线程写入的值,要么是默认值。
最小安全性保障和64位数据的非原子性写并不矛盾。可以理解为就算读到的是一半的无效值,那也是某个线程写入的值。

Ⅳ.JSR-133对旧内存模型的修补

JDK5之后做出的修补主要有两个:
(1)增强volatile内存语义,严格限制了volatile变量与普通变量之间的重排序。
(2)增强final的内存语义。保证了只要final引用不从构造函数中逸出,final就有了初始化安全性。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值