JAVA内存模型是一种规范,其规定了一个线程如何和何时可见由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量,他要求调用栈和本地变量放在线程栈上,对象存放在堆上。线程之间的通信必须经过主内存.目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
首先介绍几个比较重要的概念:
1 as-if-serial 语义:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。这是保证程序的执行顺序的看起来像是代码的编码顺序一样的一个核心保证,任何的编译器,runtime和处理器都必须遵守这个语义。
2 happened-before原则
happened-before 是JMM的最核心概念,对于java程序员来说,理解happened-before原则是理解JMM的关键
- 程序次序原则:一个线程内,按照代码顺序,书写在前面的操作,先行发生于书写在后后面的操作。(只是看起来而已)
- 锁定规则:一个unlock操作先行发生于对同一个锁的lock操作
- Volatile变量规则:对一个变量的写操作先行发生于对后面对这个变量的读操作
- 传递规则:如果操作A先行发生于操作B,操作B先行发生与操作C,那可以推导出来操作A先行发生于操作C
JAVA内存模型-同步的八种操作
(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
同步规则分析:
1)不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步会主内存中
2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作。
3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
并发编程的问题
1原子性问题
2可见性问题
3有序性问题。
为了解决上述的三个问题,Java提供了一系列和并发处理相关的关键字,比如volatile
、synchronized
、final
、concurren
包等
可见性(Volatile,Synchronized,Lock)
有序性(Volatile,Synchronized, Lock)
原子性(Synchronized, Lock,actomic包)
volatile关键字的两层内存语义
1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
2)禁止进行指令重排序。
JVM通过在对volatile变量的读写前后都会插入内存屏障来实现有序性和可见性。
JMM对于volatile变量采用保守策略:
1 在每个volatile写操作的前面插入一个 StoreStore屏障
2 在每个volatile写操作的后面插入一个 StoreLoad屏障
3 在每个volatile读操作的后面插入一个 LoadLoad屏障
4 在每个volatile 读操作的后面插入一个LoadStore屏障
java内存屏障
- java的内存屏障通常所谓的四种即
LoadLoad
,StoreStore
,LoadStore
,StoreLoad
实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。 - LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
Volatile变量仅仅保证了可见性与有序性,所有在并发编程中使用volatile并不是绝对安全的。(部分代码演示,以及简单的使用场景)
Lock的java内存语义
1 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
2 当线程释放锁时,JMM会把该线程对应的本地内存的共享变量刷新到主内存中。
通过对比可以发现,获取锁与volatile读具有相同的语义。释放锁与volatile写具有相同的语义,这样Lock就可以保证了可见性与有序性。而java中的锁(以ReentrantLock为例),都借助了CAS原理来保证了原子性。
Synchronized的内存语义
原子性
被 synchronized 修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在 Java 中可以使用 synchronized 来保证方法和代码块内的操作是原子性的。
可见性
对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
有序性
synchronized 本身是无法禁止指令重排和处理器优化的,但是由于synchronized的锁机制,导致同一时间只能被同一线程执行,加上提到的as-if-serial 语义。所以,可以保证其有序性。
使用场景
1 修饰代码块:大括号括起来的代码,作用于调用的对象
2 修饰方法:整个方法,作用于调用的对象
3 修饰静态方法: 整个静态方法,作用于所有对象
4 修饰类: 括号括起来的部分,作用于所有对象
在JAVA中,synchronized 关键字,Lock,volatile关键字都有实现线程安全的作用,
但是从性能上来说,volatile要优于Lock 和synchronized,但是volatile如果使用不当,也会出现线程不安全的情况,synchronized的使用相对于Lock要简单,在并发度相对低的情况,synchronized性能要高于Lock,但是在并发量大的情况下,性能上要差一些,Lock还提供了一些synchronized无法满足的特性,1尝试非阻塞的获取锁,2能被中断的获取锁 3 超时获取锁
先来介绍一下原子变量类
Java中提供的原子变量类主要在JUC的actomic包中。提供了ActomicXXX等多个类来保证线程安全性。
主要介绍一下ActomicLong,LongAddr(部分代码演示) 以及使用ActomicStampReference来解决CAS中遇到的ABA问题。
接下来介绍一下如何的安全发布对象(结合N多种单例模式进行讲解)
1 在静态初始化函数中初始化一个对象应用
2 将对象的应用保存到volatile类型域或者ActomicReference对象中
3 将对象的引用保存到某个正确构造对象的final类型域中
4 将对象的应用保存到一个由锁保护的域中
线程安全的策略:
不可变对象
一 使用final关键字
1 将类声明为final
2 将所有的成员声明为私有的,private修饰
3 对变量不提供set方法
4 将所有可变的成员声明为final
5 通过构造器初始化所有成员进行深度拷贝
6 在get方法中不直接返回对象本身,而是拷贝对象,返回对象的拷贝
二 Collections.unmodifiableXXX: Collection List ,Mpa Set …
三 Guava的ImmutableXXX
线程封闭
1 局部变量
2使用ThreadLocal(具体案例可以参考brain或者是web项目中的一些关于用户状态的一些标识)
同步容器
1 ArrayList -> Vector(如果使用不当,容易出错,多线程情况下出错概率更高),Stack
2 HashMap -> HashTable(K,V不能为空)
3 Collections.synchronizedXXX(List,Set,Map)
由于同步容易采用的是synchronized 关键字做的线程安全处理,所以在性能上会有一定的损失,在高并发的场景下,同步容器并不推荐使用。
并发容器(J.U.C)
CopyOnWriteArrayXXX(适合读多写少的情景 1 读写分离 2 最终一致性) 复制出一个副本,然后进行update操作,完成之后,把变量指向新的对象。(对CopyOnWriteArrayList的读操作是不加锁的,写操作是加锁的,)
- 因为需要做副本拷贝,所以会消耗内存空间,如果对象过大,容易造成频繁的GC
- 因为读操作是在原对象上进行的,所以具有一定的延时性,只能保证最终一致性。
ConcurrentHashMap(这里不做过多介绍,网上资料多的很,非常详细)
ConcurrentSkipListMap(TreeMap的并发容器)1 K有序2 并发量更高,存取时间是和线程数没有关系(在高并发场景下有非常好的表现)
AQS
CountDownLatch 计数器向下闭锁
Semaphore 信号量
CyclicBarrier 同步屏障
Lock
在使用阻塞等待获取锁的方式中,必须在 try 代码块之外,并且在加锁方法与 try 代 码块之间没有任何可能抛出异常的方法调用,避免加锁成功后,在 finally 中无法解锁。
说明一:如果在 lock 方法与 try 代码块之间的方法调用抛出异常,那么无法解锁,造成其它线程无法成功 获取锁。
说明二:如果 lock 方法在 try 代码块之内,可能由于其它方法抛出异常,导致在 finally 代码块中, unlock 对未加锁的对象解锁,它会调用 AQS 的 tryRelease 方法(取决于具体实现类),抛出 IllegalMonitorStateException 异常。
ReentrantLock 可重入锁
J.U.C组件扩展
1 FutureTask(对Callable 和Future接口的组合封装)
面试中经常被问到的多种创建线程的方式(Thread Runable Callable FutureTask)
2 ForkJoin (java中的MapReduce)
3 BlockQueue java中的阻塞队列,线程安全
使用不同的方法会有不同的表现,如下图
线程调度- 线程池
new Thread 的弊端
1 每次new Thread新建对象,性能差
2 线程缺乏统一的管理,可能无限制的新建线程,相互竞争,有可能占用过多的系统资源导致死机或者OOM
3 缺少一些高级功能,如更多执行,定期执行,线程中断
线程池的好处
1 重用存在的线程,减少对象创建,消亡的开销,性能高
2 可有效的控制最大并发数,提高系统资源利用率,同时可以避免过多资源竞争,避免阻塞
3 提供定时执行,定期执行,单线程,并发数控制等功能
ThreadPoolExecutor 属性:
corePoolSize:核心池的大小
maximumPoolSize:线程池最大线程数
keepAliveTime:表示线程没有任务执行时最多保持多久时间会终止。默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize
unit:时间单位
workQueue:一个阻塞队列,用来存储等待执行的任务,这个参数的选择也很重要,会对线程池的运行过程产生重大影响,一般来说,这里的阻塞队列有以下几种选择:ArrayBlockingQueue LinkedBlockingQueue SynchronousQueue
threadFactory:线程工厂,主要用来创建线程;
handler:表示当拒绝处理任务时的策略
1 ThreadPoolExecutor.AbortPolicy() 直接抛出异常
2 ThreadPoolExecutor.CallerRunsPolicy 直接在主线程中执行
3 ThreadPoolExecutor.DiscardOldestPolicy 丢弃靠前的任务
4 ThreadPoolExecutor.DiscardPolicy 丢弃该任务
ThreadPoolExecutor池子的处理流程如下:
1)当池子大小小于corePoolSize就新建线程,并处理请求
2)当池子大小等于corePoolSize,把请求放入workQueue中,池子里的空闲线程就去从workQueue中取任务并处理
3)当workQueue放不下新入的任务时,新建线程入池,并处理请求,如果池子大小撑到了maximumPoolSize就用RejectedExecutionHandler来做拒绝处理
4)另外,当池子的线程数大于corePoolSize的时候,多余的线程会等待keepAliveTime长的时间,如果无请求可处理就自行销毁 其会优先创建 CorePoolSiz 线程, 当继续增加线程时,先放入Queue中,当 CorePoolSiz 和 Queue 都满的时候,就增加创建新线程,当线程达到MaxPoolSize的时候,就会根据配置的拒绝策略处理
newSingleThreadExecutor:创建一个单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。
注:阿里的开发手册中提到过线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这 样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
1) FixedThreadPool 和 SingleThreadPool: 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
2) CachedThreadPool: 允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM
附件为测试用例源码
https://download.youkuaiyun.com/download/qq_32725403/11527587