内存模型

(1)java内存模型到底是个啥子东西?

java内存模型是java虚拟机规范定义的一种特定模型,用以屏蔽不同硬件和操作系统的内存访问差异,让java在不同平台中能达到一致的内存访问效果,是在特定的协议下对特定的内存或高速缓存进行读写访问的抽象。我来简单的总结成一句话就是:java内存模型是java定义的对计算机内存资源(包含寄存器、高速缓存、主存等)的读写方法和规则。 注意上面定义是我个人的理解。
随着我们计算机技术的不断发展,计算机的运算能力越来越强,cpu和存储及通信子系统的速度差距越来越大,为了避免将大量宝贵的计算资源浪费在数据库查询、网络通信等IO操作上,现在多线程开发已经成了我们必需的技能。而多线程开发面临的最大问题就是数据一致性问题,线程之间如何读到各自的数据?线程之间如何进行交互?这些都是很重要的问题。另外编译器在编译程序时会自动对程序进行重排序,cpu在执行指令时也会通过指令乱序的方式来提高执行效率,高速缓存也会导致变量提交到内存的顺序发生变化,同时不同处理器高速缓存中的数据互相不可见,这些都导致从一个线程看另一个线程,另一个线程的内存操作似乎在乱序执行。
为了解决这些问题,java内存模型规定了一组最小保证,这组保证规定了对变量的写入操作在何时对其他线程可见,同时也会保证在单线程环境中程序的执行结果与在严格串行环境中执行的结果相同(在本线程中好像顺序执行一样)。

(2)主内存和工作内存的规定

jvm虚拟机的主要目标是定义共享变量的访问规则,java内存模型在设计时为了保证性能在可预测性和易开发性间进行了平衡,它并没有限制编译器的重排序优化,也没有限制执行引擎使用处理器的寄存器和缓存与主存进行交互,在跨线程的共享数据处理中,我们仍然需要使用合适的同步操作访问共享数据。
在java内存模型中定义了“主内存”和“工作内存”两个概念,我们可以将主内存类比为我们计算中的内存,将工作内存类比为我们cpu中的高速缓存和寄存器,但是实际上他们并不是等价关系。java内存模型规定:所有变量都储存在主内存中,线程对变量的所有操作都必须在工作线程中,每个线程都有自己的工作内存,他们之间互相无法访问,线程间的交互需要通过主内存。
在主内存和工作内存的基础上,java内存模型定义了8个最基本的原子操作,用以处理主内存和工作内存的交互。

  1. lock:锁定内存变量
  2. unlock:解锁内存变量
  3. read:读取主内存内的变量
  4. load:将read读取的变量写入到工作内存中
  5. use:从工作内存中读取变量到执行引擎
  6. assign:将执行引擎的变量数据写到工作内存中
  7. store:读取工作内存的变量
  8. write:将工作内存中的变量写入到主内存中
(3)volatile的语义

在日常的开发中我们经常能听大家谈论volatile关键字,但实际上具体是如何实现的大部分人都不清楚,实际上原理并不复杂。volatile具备两个关键的特性,一个是保证变量对所有线程的可见性,另一个是禁止指令重排序(包括cpu层面的指令乱序)。volatile抽象逻辑上通过“内存栅栏”实现,其使用的“栅栏”如下所示:

每个volatile写操作前会插入StoreStore栅栏,写操作后会插入StoreLoad栅栏
每个volatile读操作前会插入LoadLoad栅栏,读操作后会插入LoadStore栅栏

在字节码层面,volatile通过lock指令实现,在volatile变量写操作后会有一个lock addl ¥0x0, (%esp) 的命令,这个命令会将变量数据立即刷到主内存中,并利用cpu总线嗅探机制使其他线程高速缓存内的cacheline失效(cacheline是cpu高速缓存cache的基本读写单位),使用时必须重新到主内存Memory读取。同时因为需要立刻刷数据到内存中,那么volatile变量操作前的所有操作都需要完全执行完成,这样进而也保证了volatile变量写操作前后不会出现重排序。通常volatile变量的读写效率和普通变量没有多大差别,但在volatile变量并发访问冲突非常频繁的情况下可能造成性能的下降,具体的例子及解决方案可以百度“伪共享”问题。

(4)杠杠的先行发生原则

java内存模型主要是通过各种操作的定义实现的,包括内存变量的读写操作、监视器锁定释放、线程关闭启动等等。java内存模型为所有的这些操作定义了一套偏序关系,我们称之为先行发生规则(happens-before)。线程A要看到线程B的结果,那么线程A和线程B必须满足happens-before原则,如果不满足就可能会出现重排序。下面是具体的规则:

  • 程序顺序规则:在同一个线程内,按照代码书写顺序,写在前面的代码一定先于后面的代码执行。这种单线程代码有序是jvm通过内存栅栏帮我们实现的。
  • 管程锁定规则:同一个锁,unlock一定发生在lock前
  • volatile规则:volatile的写操作先行发生于读操作
  • 线程启动规则:线程start方法先行于线程内所有操作
  • 线程关闭规则:线程所有操作先行发生于线程关闭操作
  • 对象终结规则:对象构造操作先行发生于它的finalize()方法
  • 传递性:如果A先行发生于B,B现行发生于C,那么A先行发行于C。

注意先行发生并不代表时间上的先后! 举两个小🌰
(1)函数A进行set操作,函数B进行get操作,即时时间上A先执行B后执行,B也不不一定能读到A set的值,很可能刚好函数A指令还没执行好,线程的时间片就没了,然后B函数获得了cpu时间片并执行完成,这时B函数根本读不到A设置的值。
(2)另外即使是A操作先行发生于B操作,那么A操作也不一定在时间上先于B操作执行,假如AB间没有依赖关系,那么很可能在时间上B先执行,因为jvm只会帮我们保证最终的执行结果与严格顺序执行的结果相同,不存在依赖关系的变量或操作间仍可能重排序优化。

线程安全
(1)线程安全的定义

在并发开发中,我们首先需要保证并发的正确性,然后在此基础上实现高效代码的开发。在日常开发中,我们通常会将能够安全的被多个线程使用的对象称为线程安全对象,但这样说可能仍不够严谨,我们可以借用《java并发编程实战》中的定义:当多个线程访问一个对象时,如果不用考虑线程在运行时环境的调度和交替执行,也不需要额外的同步和调用方的操作协调,直接使用这个对象都能获得正确的结果,这个对象就是线程安全的。

(2)Java中的线程安全级别

通常在java中我们可以将java按安全性强弱分为几个级别:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立,接下来我们分别简单的介绍下。

  • 不可变
    在java中不可变对象一定是线程安全的,线程安全是不可变对象的固有属性之一,它们的不变条件是由构造函数创建的。我们需要注意的是java中目前没有不可变对象的明确定义,一般情况下如果对象的所有状态变量都是不可变的,那么对象就是不可变对像(即使对象的所有域都为final类型,对象也不一定是不可变的,因为有些域是引用类型。当final修饰的引用类型时,只能保证引用地址是不变的,实际指向的对象仍然可能发生状态变更)。
    另外经常会看见某些同学喜欢用final修饰局部变量,其实没啥卵用。因为class文件在设计时,对于局部变量和字段(实例变量、类变量)是区别对待的。字段在class中有access_flags属性用来记录字段的修饰符,例如final、static、private等。而局部变量是没有这个属性信息的,使不使用final修饰局部变量,在经过javac的编译后生成的class文件是一模一样的。
  • 绝对线程安全、相对线程安全
    绝对线程安全的实现通常需要付出非常大的代价,我们平时开发中声明为线程安全的类也并不是绝对线程安全的,而实际上指的是相对线程安全。
  • 线程兼容
    实际上我们通常说的线程不安全对象,例如HashMap、ArrayList等,其实在java中都是被定义为线程兼容类型,指我们可以通过额外的同步操作保证线程安全。
  • 线程对立
    指无论调用是否采用同步措施,都无法并发使用,比如旧版本中的suspend()和 resume()方法。
(3)保障线程安全的一些措施
  • 阻塞同步:
    synchronized的语义
    在并发编程中,我们最常用的同步手段就是synchronized,synchronized是java提供的可以保证原子性的内置锁,是具有排他性的可重入锁,同一个线程可以多次使用已经获得的synchronized锁。
    synchronized的底层实现依赖于jvm用C++实现的管程(ObjectMonitor),管程是一种类似于信号量的程序结构,它封装了同步操作并对进程隐蔽了同步细节。其整体实现逻辑和ReentrantLock很相似,大致的结构原理可以参见:Synchronized之管程
    我们在使用synchronized时通常有两种方式:1.修饰方法、2.修饰代码块,其实两者差别不大,本质上都是同步代码块。在虚拟机层面上,当用synchronized修饰方法时,class文件中会在方法表中为相应方法增加ACC_SYNCHRONIZED访问标志,用以标识该方法为同步方法。而当用synchronized修饰代码块时,会在相应代码段字节码的前后分别插入monitorenter和monitorexit字节码指令,用以表示该段代码需要同步。
    当线程执行到相应的方法或代码段时,需要先获取对象的锁。如果对象没有被锁定或当前线程已经拥有了该锁,则将锁计数器的值加1然后执行代码,相应的在退出方法或代码段时,需将计数器的值减1。而如果获取锁失败,则当前线程就需要阻塞等待,直到锁被释放。
    由于java线程是通过映射到内核线程实现的,线程的挂起和唤醒都需要操作系统的帮助,需要从用户态切换到内核态,需要耗费很多cpu资源,所以synchronized相对而言是一种比较重的锁(不过随着不断优化,jvm通过自适应自旋、锁消除/锁粗化、锁升级等逐渐让synchronized显得没那么重了),需要在合适的场景恰当的使用。
    个人经验之谈:在使用synchronized时,我们可以把synchronized修饰的方法或代码段想象成一段不可以并发访问的临界区资源,这种资源必须独占使用。而如何实现独占访问呢?我们可以想象每个对象都有把独占锁,我们需要借助某个对象的独占锁来访问这种临界区资源,而同一个锁某个时刻只能被一个线程所获取,其他线程都得等待锁的释放。用synchronized修饰的实例方法(public synchronized void method())默认使用当前对象(this)的锁,而用synchronized修饰的静态方法(public synchronized static void method())默认使用当前对象对应的Class对象锁,他们分别对应于synchronized修饰代码块中的synchronized(object)和synchronized(Object.class)。Class对象存在于方法区中,具有全局唯一性,在一个jvm实例中一个Class对象只有一把锁,所有使用该Class对象作为锁的静态方法或代码块,执行前都必须先获得该Class对象锁。而同一个Class可以有很多实例对象,每个实例对象都有一个自己的锁,使用实例对象A锁的线程和使用实例对象B锁的线程间不存在竞争关系。
    除了synchronized外,DougLea也帮我们实现了ReentrantLock,它和synchronized功能和实现逻辑都基本相似,不过提供更多个性化的功能,大家有时间可以学习下。
  • 非阻塞同步:
    从概念模型角度出发,我们可以认为阻塞同步是一种悲观的并发策略,无论是否存在并发竞争,都需要先加锁后进行操作。而非阻塞同步是一种基于冲突检测的乐观并发测策略,它会先执行相关操作,在提交阶段才进行冲突检测,如果存在冲突再进行相应补偿,这种并发策略大部分时候不需要将线程挂起。
    最经典的非阻塞同步就是CAS,就像它的名字compare and swap一样,在提交数据前它会比较数据版本是否正确,以决定是否提交数据。我们java API中有大量的CAS应用,譬如AQS:它会维护一个volatile state变量和一个双向链表,通过CAS操作state变量,然后根据state变量的值决定线程是挂起放入双向链表中还是获得执行权。
  • 无同步方案:
    除了上面两种同步方案外,我们还有很多代码是无状态的、本身并不需要同步的,我一般称这种代码为纯代码。这种代码无任何状态,它们不依赖堆中数据和公用的系统资源,也是线程安全的。
    另外如果如果我们能将共享数据的可见范围限制在同一个线程范围内,那么无需同步也能保证线程间不出现数据争用问题。我们可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。
    ThreadLocal实现介绍
    ThreadLocal的实现并不算复杂,首先每个线程Thread对象都维护了一个ThreadLocalMap,这个Map是由ThreadLocal类实现的一个使用线性探测的自定义Map,Map的key是ThreadLocal对象的引用,而value就是我们需要存储的本地线程变量。
    如下所示当我们使用ThreadLocal时,需先new一个ThreadLocal对象threadLocalA,当使用threadLocalA保存本地线程变量(“东哥真帅!”)时,会先获取当前Thread对象中的ThreadLocalMap,然后将对象threadLocalA的引用作为key,本地线程变量(“东哥真帅!”)作为value存进ThreadLocalMap中。
// ThreadLocal使用方式
   ThreadLocal<String> threadLocalA = new ThreadLocal<>();
   threadLocal.set("东哥真帅!");
   
   // ThreadLocal.set源码
   public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    
    
   // ThreadLocalMap.set部分源码
   private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ........
            }
        }
    ........
    
 
    // ThreadLocalMap类部分源码
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
            ........
        }
    ........

值得注意的是ThreadLocalMap并没有使用拉链法,而是使用了线性探测法,并且为了提高key的离散度/减少key冲突,没有使用对象自身的HashCode,而是使用了自定义的threadLocalHashCode。

另外还有一点非常重要:ThreadLocalMap的key被封装成了弱引用。当ThreadLocal对象threadLocalA没有其他强引用时,在下次GC来临时threadLocalA就会被回收,同时ThreadLocalMap相应槽位的key值会变为null,ThreadLocalMap在每次进行get/set操作时都会主动的去清空key为null的键值对。ThreadLocal的这种设计主要是为了防止出现内存泄露。假如key为强引用,那么当threadLocalA使用完后,ThreadLocalMap仍持有threadLocalA的强引用,将会导致threadLocalA无法回收。

java 类的内存模型 java内存模型的理解_数据


顺便提下java中的几种引用类型,主要有强引用、软引用、弱引用、虚引用。相关的知识可以参见:Java 的强引用、弱引用、软引用、虚引用。

  • 强引用(StrongReference):强引用就是平时我们new出来的对象引用,当对象生命周期结束时才会被回收。
  • 软引用(SoftReference):软引用在内存空间不足时会被GC回收,内存充足时不会被回收。
  • 弱引用(WeakReference):弱引用只能活到下次GC到来。
  • 虚引用(PhantomReference):虚引用就相当于没有引用,但虚引用会绑定一个ReferenceQueue引用队列,当对象被回收时相关联的虚引用就会被放入ReferenceQueue引用队列中,可以用来释放特定的资源。比如我们可以把jdbcConnection封装成虚引用,同时虚引用中记录jdbcConnection使用的堆外内存数据。当jdbcConnection被回收时,我们就可以在ReferenceQueue引用队列通过虚引用去主动释放堆外内存数据。
(4)锁竞争优化方案

在并发程序中,对伸缩性的最主要威胁就是独占方式的资源锁。在独占锁上发生竞争将导致线程操作串行化和大量上线文切换,所以尽量降低和减少锁的竞争可以提升性能以及提高程序的可伸缩性。影响锁竞争的两个最重要的因素是:1.锁的请求频率,2.锁的持有时间。接下来介绍几种减少锁竞争的方案。

  1. 缩小锁的范围(快进快出)
    将一些与锁无关的代码移除同步代码块,尤其是时间开销比较大的操作,可以有效的缩短锁的持有时间,进而降低锁竞争。
  2. 锁分解
    如果一个锁用来保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,每个锁只保护一个变量,这样就能够降低每个锁的请求频率。进而提高程序的可伸缩性。
    举个阻塞队列的例子:大部分的阻塞队列都会有个队列,生产者线程池不停生产数据到队列中,当队列满了就阻塞生产者线程,而生产者线程池不停消费队列中的数据,当队列空了就阻塞消费者线程,这时我们可以使用一个全局的ReetrantLock.Condition用于阻塞生产者线程或者消费者线程,但更好的方案是使用两个ReetrantLock.Condition分别负责阻塞生产者线程和消费者线程,这实际上就是一种锁分解。如下为LinkedBlockingQueue的部分代码,其中notEmpty和notFull两个条件锁的使用实际上体现的就是锁分解的思想。
private final ReentrantLock takeLock = new ReentrantLock();
    private final Condition notEmpty = takeLock.newCondition();

    private final ReentrantLock putLock = new ReentrantLock();
    private final Condition notFull = putLock.newCondition();

    public boolean offer(E e, long timeout, TimeUnit unit)
        throws InterruptedException {

        if (e == null) throw new NullPointerException();
        long nanos = unit.toNanos(timeout);
        int c = -1;
        final ReentrantLock putLock = this.putLock;
        final AtomicInteger count = this.count;
        putLock.lockInterruptibly();
        try {
            while (count.get() == capacity) {
                if (nanos <= 0)
                    return false;
                nanos = notFull.awaitNanos(nanos);
            }
            enqueue(new Node<E>(e));
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
        return true;
    }
  1. 锁分段
    锁分解是利用系统内相互独立的状态变量来进行锁的拆分,但大部分系统中相互独立的状态变量并不多,当锁的竞争非常激烈时,这种拆分的性能提升是有限的。在某些情况下,我们可以对系统内一组独立对象上的锁进行拆分,这种拆分的方式被称为锁分段。一个最经典的例子就是旧版的ConcurrentHashMap,在旧版ConcurrentHashMap的实现中使用了包含16个锁的数组,每个锁保护散列桶的16分之1,这其实体现的就是锁分段的思想。新版ConcurrentHashMap分的就更细了,每个桶都有一个锁,具体的细节大家有时间可以去学习下。
  2. java 类的内存模型 java内存模型的理解_java_02

  3. 大家有没有感觉到,锁分解有点类似于垂直分库,而锁分段有点像水平分表,看来优秀的设计思想都很相似。
  4. 避免使用独占锁
    在业务允许的情况下,我们也可以通过避免使用独占锁来降低锁的竞争。例如,在读取操作比较多的时候,我们可以用ReadWriteLock来代替ReetrantLock,这样能提供更高的并发性和性能。对于一些访问频率非常高的热点变量数据,我们可以使用原子变量来操作,也可以用volatile+CAS来代替,这些都能够有效的提升系统性能。

java 类的内存模型 java内存模型的理解_数据_03