Java并发编程的艺术(二)

第三章 java内存模型的基础

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

1).线程之间如何通信,线程之间通信机制:共享内存和消息传递

2).线程之间如何同步

java的并发采用的是共享内存模型

2.java内存模型的抽象结构

1).所有实例域,静态域,数组元素都存储在堆内存当中,这三块区域都是堆内存在线程之间的共享

2).Java线程之间的通信由内存模型JMM控制,JMM决定了一个线程堆共享变量的写入何时堆另一个线程可见.

线程之间的 共享变量储存在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory)

JMM是一个抽象的概念,它包含了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化

3.线程之间通信的步骤

1).线程A把本地内存A中更新过的共享变量刷新到主内存当中去

2).线程B到主内存中去读取线程之前已经更新过的共享变量

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

java在执行程序的时候,为了提高性能,编译器和处理器常常会对指令做重新排序,充排序分成了3中类型

1).编译器优化的重排序.编译器在不改变单线程程序语义的前提下可以重新安排语句执行顺序

2).指令级并行的重排序.

3).内存系统的重排序.

总结:java的JMM会要求java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器排序.JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性的保证

5.并发编程模型的分类

并发编程在做写操作的时候都是操作本地内存的缓冲区的缓存数据,每次缓存区域有了写操作之后就会把内容更新到本地内存当中,当读取的数据的时候就是直接从本地内存当中进行读取

从上面的推论我们可以得出,我们在高并发的时候会有很多的线程,可能有读取的线程和写入的线程,那么为了保证数据的完整性,JMM的优化就是发送指令给对应的处理器,让这些处理器对对应的线程进行重新排序,从而起到了优化数据的结果

6.hapens-before简介

从JDK5开始,Java使用新的JSR内存模型,JMM中规定如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须要存在happens-beffore关系

  1. happens-before规则

a) 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作

b) 监视器锁规则

c) Volatile变量规则

d) 传递性

happens-before本质上有点类似于保证程序的顺序执行

7.重排序

对于重排序而言,都必须遵循as-if-serial,意思是 不管怎么进行重排序,程序的执行结果不能被改变,但是要记住,重排序是针对于多线程并发,有多个线程的时候才存在重排序,对于单线程执行的时候,就不存在对单个线程进行重排序的问题

8.顺序一致性内存模型

1).一个线程中所有操作必须按照程序的顺序来执行

2).所有线程都只能看到一个单一的操作执行顺序,并且每个操作都必须要保持原子性

总结:JMM在不改变程序执行结果的前提下,尽可能的为比编译器和处理器的优化打开方便之门

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

对于未同步或者未正确同步的多线程程序,JMM只是提供最小安全性,线程执行时读取到的值,要么是之前某个线程写入的值,幺妹是默认值(0,false,null),JMM能保证的是线程读取到的值不会无中生有

10.处理器处理内存的时候是使用总线调度机制,其实也没有真正的调度,本质上采用的还是抢占式调度

11.JSR-133的内存模型

在JDK5之前,内存模型会吧long/double的读和写都拆分成为两个32位的数来进行操作,在JSK5之后只允许写的时候把他们进行拆分成为两个32位,但是读取的时候就是按照64位进行读取的了

Volatile的特性

  1. 可见性.对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入

  2. 原子性:对任意单个volatile的读/写具有原子性,但是volatile++这种情况下符合操作不具有原子性

  3. 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存中,简而言之就是一个轻量级的锁,保证数据的一致性和操作的原子性

  4. 线程A写一个volatile变两个,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

  5. JMM在遇到volatile修饰的变量时不能重排序的情况

a) 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序

b) 当第一个操作是volatile读时,不管第二个操作是什么,都不能重新排序

c) 当第一个操作是volatile写,第二个操作是volatile读时

  1. 为了实现volatile不能重排序的功能,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

  2. volatile的内存屏障

a) volatile写前插入StoreStore屏障

b) volatile写后插入StoreLoad屏障

c) volatile读前插入LoadLoad屏障

d) volatile读后插入语LoadStore屏障

备注:在jdk1.5之前JMM是允许对volatile修饰的变量进行重排序的

锁的私房和获取的内存语义

锁的意义主要是锁住线程正在操作的本地内存区域,当锁释放的时候就把本地内存的变量刷新到主内存当中去,然后再发送消息给下一个要获取锁的线程发出了线程A已经对共享变量所做修改的消息

总结:

  1. 线程A释放了一个锁,实质上是线程A向接下来要获取这个锁的某个线程发出消息

  2. 线程B获取一个锁,实质上是线程B接受了之前某个线程发出的消息

  3. 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息

  4. 备注:实际上使用volatile修饰的变量也是以这样的形式进行操作的

ReentranLock锁机制

  1. 在ReentranLock中,调用lock()方法获取锁,调用unlock()方法释放锁

  2. ReentranLock的实现以来于java同步器框架AbstractQueuedSynchronizer(即是AOS),底层使用的是volatile来进行维持同步状态

  3. ReentranLock分为了公平锁和非公平锁

  4. 在这个锁机制当中调用了本地方法使用C++的代码,所以这部分的内容其实就是在openjdk\hotspot\src当中,做了一些对于处理器的处理,在这些代码当中针对于不同的操作系统做了一些不同的操作,

a) 在jdk当中内置了对于当前操作系统的一些判断,如果是单处理器,此时不存在并发操作,所以它在程序执行的时候就不会加锁,如果是多处理器,那就会对程序进行加锁

  1. 处理器当中对于Lock的说明

a) 在不同的处理器当中对于锁Lock的处理方式也略有不同,例如在Pentium4以前对于锁的处理就是锁住了调度的总线,也就意味着只要有一个线程执行了,那么其它线程都无法进入总线当中进行调度

b) 把写缓冲区中的所有数据都刷新到内存当中

  1. 总结公平锁和非公平锁

a) 公平锁和非公平锁释放时,最后都需要写一个volatile变量state

b) 公平锁获取时,首先会去读volatile变量

c) 非公平锁获取时,首先会用CAS更新volatile变量

Concurrent包的实现

  1. Java线程之间的通信的4种方式

a) A线程写了volatile变量,随后B线程读这个volatile变量(备注:本质上就是线程A读取了本地内存当中的共享变量的副本,在释放锁的时候将本地内存当中修改的内容刷新到共享内存当中,并且通知下一个即将获得锁的线程B,从而形成了线程之间的通信)

b) A线程写volatile变量,随后B线程使用CAS更新这个volatile变量(备注:CAS的本质就类似于用于保证程序的顺序执行,CAS的本质类似于锁)

c) A线程使用CAS更新一个volatile变量,随后线程B用CAS更新这个volatile变量

d) A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量

关于锁,volatile,CAS算法的总结:

  1. 锁机制存在的问题

a) 多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时问题

b) 一个线程持有锁会导致其它所有需要此锁的线程挂起直至该锁释放

c) 如果一个优先级高的线程等待一个优先级低的线程释放锁,会出现优先级反转的问题,在JMM中会对线程进行重排序,将写线程优先于读线程,对于那些写线程和读线程进行了一定的标记

d) 乐观锁和悲观锁:独占锁是一种悲观锁,synchronized就是一种独占锁,会导致所有需要锁的线程挂起

  1. Volatile

a) 相比于锁来说,volatile变量是一种轻量级的同步机制,在使用volatile的时候不会发生上下文的切换和调度问题

  1. CAS是一种无锁的算法

a) CAS内部以原子操作为基础,采用事务提交提交失败重试这样的特性,就是在多线程并发的时候采用抢占式的,如果线程A正常执行了,那么其它线程就表示失败的状态,但是其它线程会继续进行下一次的执行,直到所有的线程都执行完毕了,CAS就会停止

  1. AQS,非阻塞数据结构和原子变量类,concurrent包中的基础类都是使用这种模式来实现的,

Final作用域的重排序规则

  1. 在构造函数内对一个final作用域写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序

  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

写final域的重排序规则

  1. JMM禁止编译器把final域的写重排序到构造函数之外

  2. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障

Final语义在处理器当中的实现

X86处理器中,final域的读/写不会插入任何内存屏障

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

在以前旧的Java内存模型当中,final的初始化跟构造器同步,此时final修饰的变量是对应的默认值,直到线程执行的时候把这个final赋值,所以这个时候对于final赋值的变量而言就是会改变的

Happens-before

Happens-before是JMM最核心的概念

JMM把happens-before要求禁止的重排序分为了两类

  1. 会改变程序执行结果的重排序,JMM会要求编译器和处理器禁止这种操作

  2. 不会改变程序执行结果的重排序,JMM会要求编译器和处理器允许这种操作

简单总结JMM,JMM会对线程程序的执行进行重排序,但是如果会影响结果就会让编译器和处理器禁止这种重排序,如果不影响结果,就允许编译器和处理器执行这种重排序

对于happens-before的规则来说是要求程序必须要顺序执行,禁止重排序,JMM的设计思路也是跟这个类似相同,但是JMM对于不影响程序执行结果的重排序就是属于允许这种重排序的操作

Happens-before规则

在JSR-133中定义了如下happens-before的规则

  1. 程序顺序规则

  2. 监视器锁规则

  3. Volatile变量规则

  4. 传递性

  5. Start()规则

  6. Join()规则

双重检查锁定的由来

在Java程序当中,有时候需要推迟一些高开销的对象初始化,并且只有在使用到的时候才去创建它,例如单例设计模式加上延迟初始化,例如下代码


public class UnsafeLazyIntialization{

        private static Instance instance;

        public static Instance getInstance(){

            if(instance == null){	//第一次检查

                synchronized(DoubleCheckedLocking.class){ //第二次检查

                    if(instance == null){

                        instance = new Instance();

                    }

                }

                return instance;

            }

        }

    }

总结:以上就是双重锁定检查,相对来说降低了很多对于synchronized的开销

Java内存模型的总结;

Java内存模型总结:

  1. 处理器内存模型:

a) 顺序一致性内存模型:

i. JMM和处理器内存模型会对顺序一致性做一些放松,如果完全按照顺序一致性模型来实现处理器和JMM,那么很多处理器和编译器优化都要被禁止

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

a) 增强了volatile的内存语义.旧内存模型允许volatile变量和普通变量重排序.JSR-133严格限制volatile变量和普通变量的重排序,但是都是使用volatile变量修饰的

b) 增强了final的内存语义.在旧内存模型中,多次读取同一个final变量的值可能会不相同,因此JSR-133位final增加了两个重排序规则规则,保证final引用不会从构造函数内溢出的情况下,final具有了初始化安全性

猜你喜欢

转载自blog.csdn.net/weixin_42528266/article/details/89010867