目录
一、Java内存模型与线程
- 概述
- Java内存模型
- 对于Volatile型变量的特殊规则
- 原子性、可见性、有序性
- 先行发生原则
- Java与线程
- Java与协程
二、线程安全与锁优化
- 操作共享数据情况分类
- 线程安全的实现方法
- 锁优化
一、Java内存模型与线程
1、概述
- 对于计算量相同的任务,程序线程并发协调的越有条不紊,效率自然就越高,反之,线程之间频繁征用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力
- 现代计算机基本都是内存-缓存-主存三层结构,将运算的数据放到缓存中提高运算速度,结束之后从缓存同步到主存,这样处理器就无须等待缓慢的内存读写了,但是缓存引入的问题就是缓存一致性问题,多路处理器系统中,每个都处理器都对应自己的缓存,当同时对主存中数据运算时,将可能导致自己缓存数据的不一致,因此需要遵循一些协议
- 除了增加缓存提高速度,为了使处理器内部的运算单元能尽量被重复利用,处理器可以对输入代码进行乱序执行优化,引出Java虚拟机即时编译的指令重排序
2、Java内存模型
JDK5(实现了JSR-133)发布后,Java内存模型才终于成熟完善,JMM规定
- 主内存:所有的变量存储在主内存(虚拟机内存一部分,可类比电脑内存)
- 工作内存:每条相乘有自己的工作内存,(可与前面的告诉缓存类比),保存了该线程使用变量在 主内存 的副本,线程对变量的所有操作(读取、赋值)都必须在工作内存中而不能直接读写主内存
注意
- 各个线程的工作内存私有,其他线程不能访问
- 线程之间变量值的传递均需要经过主内存来完成
内存间的交互操作
- lock:作用于主内存的变量,把该变量标示为一条线程独占的状态
- unlock
- read:作用于主内存变量,把一个变量值从主内存传至线程的工作内存,便于后续load使用
- load:作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中
- use:作用于工作内存变量,他把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的字节码指令,就会执行该操作
- assign:作用于工作内存变量,他把从执行引擎接受到的变量的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令,就会执行该操作
- store:作用于工作内存的变量,他把工作内存的一个变量值送到主内存,方便后续的write操作
- write:作用于主内存的变量,把store变量值放到主内存变量中
规则
3、对于Volatile型变量的特殊规则
关键字Volatile可以说是Java虚拟机提供的最轻量级的同步机制,当一个变量被定义为Volatile之后,它将具备两项特征
- 保证该变量对所有的线程可见性:某一个线程修改变量值后,新值对于其他线程是立即可知的
- 禁止指令重排
1.可见性
往期博客:Java并发编程学习篇5_Volatile关键字
需要注意,即使volatile变量在各个线程中都是一致的,但是也不能说在并发情况下是线程安全的
- 从物理角度来看,各个线程工作内存中的volatile变量也可以存在不一致的情况,但是每次使用之前都要刷新,因此执行引擎看不到不一致的情况,因此可以认为不存在一致性问题
- 但是Java的运算操作符并非原子操作,这导致volatile变量的运算在并发下依然是不安全的
- 如
i++ 操作,一行代码在Class文件中有4条字节码指令构成,在执行i_add、i_const等的时候,可能其他相乘已经修改了值,最后将修改后的值写入造成不安全
仍然需要通过加锁解决不安全问题
- synchronized
- juc下的lock锁、原子类(实现原理为cas自旋锁)
2.禁止指令重排
普通变量不能保证变量赋值的操作的顺序与程序代码中的执行顺序一致
- 举栗DCL(单例模式)加双锁例子:Java并发编学习篇6_单例模式、理解CAS、原子引用解决ABA问题
- 特殊指令规则:1、线程对变量use操作可以看成和load、read相关联,也就是要求在工作内存中,每次使用被volatile修饰的变量都必须先从主内存刷新最新的值,用于保证可见性;2、线程对变量的assign操作括看成和store、write相关联,每次修改完被volatile修饰的变量都必须立刻同步会主存,用于保证可见性;3、要求volatile修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同
4、原子性、可见性与有序性
1.原子性
-
我们可以认为基本数据类型(read、load、assign、use、store和write)的访问、读写都是具备原子性的,例外的就是long和double的非原子协定 -
如果场景需要更大范围的原子性保证,需要JMM提供了lock、unlock,尽管为把这些直接操作权给用户,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,反映到Java代码中就是同步块synchronized关键字
2.可见性
前面介绍的volatile修饰的变量保证可见性,除了他还有两个能保证可见性
- synchronized:同步块的可见性是由对一个变量执行unlock操作之前,必须把此变量同步回主内存中
- final:被final修饰的变量在构造器完成就被初始化,并且构造器没有吧“this”指针应用传递出去(this逃逸是很危险的,其他线程可能通过this引用访问到初始化一半的对象),那么就能在其他线程中看见final变量的值
3.有序性
如果在本线程内观察,所有的操作都是有序的,如果在另外一个线程观察另外一个线程,所有的操作都是无序的
- 前半句是指:线程内似表现为串行
- 后半句值:指令重排序和 工作内存和主存的同步延迟问题
5、先行发生原则
如果JMM内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么很多操作都会变得很啰嗦,这里有一个 先行发生的原则,是判断数据是否存在竞争,线程是否安全非常有用的手段,先行原则是JMM中定义的得两项操作之间的偏序关系
- 程序次序规则:在一个线程内,按照控制流程,书写在前面的操作先行发生于书写在后面的操作(考虑不仅仅是代码的顺序,还需要考虑分支、循环等结构)
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:写先于读
- 线程启动规则:Thread的start()先于此线程的每一个动作
- 线程终止规则:线程所有操作先于结束
- 线程中断规则
- 对象中介原则
- 传递性
6、Java与线程
线程是比进程更轻量的调度执行单位,目前线程是Java里面进行处理器资源调度的最基本单位
实现线程的三种方式
- 使用内核线程实现
- 使用用户线程实现
- 使用用户线程加轻量级进程混合
Java线程的实现
- Java线程如何实现并不受虚拟机规范的约束,这是一个与具体虚拟机相关的话题
- Java在早期的Classic虚拟机上(JDK1.2之前),是基于一种被称为"绿色线程"的用户线程实现的,但从JDK1.3起,“主流”平台上的主流商用JVM的线程模型都被替换为基于操作系统的原生线程模型实现
- 以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot不会干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理。
- 其他的虚拟机如Solaris平台的HotSpot虚拟机可以通过参数指定使用哪种线程模型
Java线程调度值得是系统为线程分配处理器使用权的过程,调度主要有两种方式
- 协同式:线程的执行时间由线程本身来控制,一个线程的任务执行完之后,需要主动通知操作系统切换到另外的线程。最大的好处就是实现简单,一般无线程同步问题,Lua语言的协同线程就是这样实现。坏处就是线程执行时间不可控制
- 抢占式:线程由系统分配时间,切换不由本身确定,在Java中有Thread::yield()方法可以主动让出执行时间,虽然线程调度是由系统自动完成的,但是我们可以给线程设置优先级给某些线程多分些时间,Java中共有10种优先级,对应操作系统的线程优先级可能不能一一对应。
状态转换
- 新建New:创建之后尚未启动
- 运行Runnable:包括操作系统线程状态中的Running和Ready,也就是此状态的线程可能正在执行,也可能正在等待操作系统为他分配时间
- 无限期等待Waiting:线程不会被分配处理器的执行时间,他们要等待被其他线程显示唤醒,以下方法会让线程无限期等待
- 没有设置Timeout参数的Object::wait()方法
- 没有设置Timeout参数的Object::join()方法
- LockSupport::park()方法
- 限期等待Timed Waiting
- Thread::sleep()
- 设置Timeout参数的Object::wait()方法
- 设置Timeout参数的Object::join()方法
- LockSupport::parkNanos()方法
- LockSupport::parkUntil()方法
- 阻塞Blocked:和等待的区别就是正在等待一个获取一个排它锁,这个事情将在另外一个线程放弃这个锁的时候发生
- 结束Terminated:线程执行中止
7、Java与协程
Java语言抽象出来隐藏各种操作系统线程差异性的统一线程接口,在此基础上,涌现很多应用程序,如HTTP和Servlet API的一条处理线程绑定,以一对一的方式处理浏览器发来的信息
随着应用规模不断变大,传统的Java Web服务器的线程池容量在几十个到几百个之间,当数以百万级的请求需要处理,系统即使能处理的过来,线程切换的开销消耗也是很可观的。
协程的复苏,内核线程调度成本主要来自 响应中断、保护和恢复执行线程的成本,即代码执行需要有上下文数据的支撑,免不了涉及一些数据在寄存器、缓存中的来回拷贝当然不是轻量级操作。
当然改为用户线程也是不能解决,一旦把保护、恢复现场的工作及调度交给程序员,,那我们就可以打开脑洞,尽可能的减少损耗
协程:由用户自己模拟多线程、自己保护上下文线程的工作模式。其原理技术在内存中划出一篇额外空间模拟调用栈,主要优势就是轻量,缺点就是需要在应用层面实现的内容较多(调用栈、调度器)特别多
正在发展的纤程就是典型的有栈协程
二、线程安全与锁优化
线程安全:当多个线程同时访问一个对象时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都能获得正确的结果,那么就称这个对象是线程安全的。
1、操作共享数据情况分类
我们可以把Java语言中的各种操作共享数据分为5类
-
不可变:由final修饰的基本数据类型(this指针没有逃逸的情况),对于共享数据是一个对象的情况,由于Java语言暂时没有提供值类型的支持,那就需要对象自行保证行为不会对其状态产生影响,如String对象,他的substring()、replace()、concat()等操作都不会影响他原来的值,而是返回新构造的字符串对象 -
绝对线程安全:并不是在API中标注了线程安全的类就一定是绝对线程安全的,如Vector集合,他的方法都有synchronized修饰,但是下面的举栗没有在方法调用端做额外的同步处理,一个线程在错误的时间删除一个元素,另外一个线程再访问就会导致越界异常 -
相对线程安全:通常讲的线程安全,我们在单次调用时不需要使用额外的同步手段保证调用的正确性,但是对于一些特定顺序的连续调用,则可能需要额外的同步手段,如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等 -
线程的兼容:对象本身是线程不安全的,但是可以在调用端正确的使用同步手段保证对象在并发环境中可以相对的使用,如ArrayList、HashMap -
线程的对立:无论如何都不能安全操作
2、线程安全的实现方法
1、互斥同步(悲观策略同步锁)
最常见也是最主要的并发正确性保障手段,保证共享数据在同一时刻只被一条(或者一些线程,当使用信号量的时候)线程使用。
互斥是实现同步的一种手段,实现方式有
在Java中synchronized是最常用的关键字,这是一种结构快的同步语法
- synchronized关键字经过javac编译之后,会在同步块前后生成monitorenter和moniterexit这两个字节码指令,这两个指令需要指定一个reference来作为锁定、解锁的对象,根据锁定的是 实例方法 还是 类方法 决定锁定 是 实例还是Class对象
- 在执行monitorenter指令的时候,首先尝试获取这个对象的锁,若此时这个对象没有被锁定或者是当前线程已有这个锁,那么就把锁的计数器值加1,执行moniterexit指令释放锁会把计数器的值减1,直到计数器的值为0锁就被释放,如果获取锁对象失败,就会一直阻塞等待
- 被synchronized修饰的同步块对于同一线程来说是可重入的,这意味着同一线程反复进入代码块不会把自己锁死
- 被synchronized修饰的同步块在当前持有锁的线程释放锁之前,会无条件的阻塞后面其他线程的进入,这也就以为无法像某些数据库锁那样强制已获取锁的线程释放锁,也不会强制正在等待锁的线程中断或者超时退出
从执行成本看,持有锁是一个重量级的操作,Java线程是映射到原生操作系统的线程,如果需要阻塞或者唤醒一条进程,需要操作系统帮忙从核心态到用户态的转换,这种转换很耗处理器的时间(关于自旋锁就是为了防止阻塞线程频繁的切入核心态之中)
在JDK1.5之后,JUC包系列提供一种新的互斥同步手段
ReentrantLock相比synchronized增加了高级功能,主要有3项
- 等待可中断:未获得锁的线程可以选择放弃等待,改为处理其他事情
- 公平锁:设置变量之后,多个线程申请一把锁可以按照申请锁的时间顺序依次获得锁,默认非公平,公平锁导致性能急剧下降
- 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,在synchronized中锁对象的wait()、notifyAll()方法可以配合实现一个隐含条件,而ReentrantLock则无需这样做,只需要newCondition()方法即可
2、非阻塞同步(乐观策略自旋锁)
互斥问题主要面对的是进行线程阻塞和唤醒带来的性能开销,无论共享的数据是否真的会出现竞争,他都会加锁(排除虚拟机优化部分锁之外来说)
随着硬件指令集的发展(保证原子性),已经有了另外选择,,基于冲突检测的乐观并发策略,通俗的说就是先进行操作
- 如果没有其他线程征用资源,操作成功
- 有其他线程争用,在进行其他的补偿措施,最长的补偿措施就是不断重试,直到没有竞争的共享数据未知
特点就是不需要阻塞等待,需要硬件保证语义上需要多次执行的行为可以通过一条处理器指令就能完成,这类指令有
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换:(Swap)
- 比较并交换:(Compare-and-Swap简称CAS)
- 加载连接、条件存储:(Load-Linked、Store-Condition)
CAS指令需要3个参数,CAS执行时,当V的值符合A时候,处理器才会更新B的值,否则他就不更新
无论是否更新V的值,都会返回V的旧值
在JDK1.5之后,Java类库才开始使用CAS操作,该操作sun.misc.Unsafe类的compareAndSwapInt() 和 compareAndSwapLong()等几个方法包装而成,虚拟机在内部对这些方法做了处理,没有方法调用过程,可以理解为无条件的内嵌出去。
Unsafe类本来就不是给用户程序调用的类,Unsafe::getUnsafe()的代码限制了只有启动类加载器(Bootstrap ClassLoder)加载的Class才能访问,因此JDK9之前只有Java类库可以使用CAS,如JUC下的整数原子类,其中的compareAndSet()和getAndIncreament()等方法都使用了Unsafe类的CAS操作实现
如果需要用户程序使用Unsafe类,需要使用反射,要么就是需要使用Java的API间接使用,直到JDK9之后,Java类库才在VarHandler类里开放了面向用户使用CAS操作
通过Java的原子类API间接使用CAS
直接使用Unsafe的CAS
increamentAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋给自己,如果失败了,证明在执行CAS时候旧的值已经发生改变(被其他线程修改了),于是再次循环进行下一次操作,这时候接旧值已经从内存更新
尽管CAS问题看起来很美好,既简单由高效,但显然这种操作无法涵盖互斥同步的所有情况,并且存在一种逻辑漏洞ABA问题
ABA问题
- 如果一个变量V初次读取的时候是A值,并且准备复制的时候检查他仍然是A,这期间可能有其他线程将值
A--->中间值--->A 的过程 - JUC包提供了一个带有标记的原子引用类AtomicStampedReference,他可以通过控制变量的版本来保证CAS的正确性
- 不过大多数情况ABA问题并不需要处理
3、无同步方案
要保证线程安全,也并非需要进行阻塞或者非阻塞同步,同步与线程安全没有必然的联系,同步只是手段,如果对于一些代码不涉及共享变量,那么就是天生线程安全的,举栗
- 1.可重入代码概念:又称纯代码,是指可以在代码执行的任何时候中断他,转而执行另外的一段代码,二组控制权返回后,原来的程序不会出现任何错误,在涉及多线程的上下文环境中(不涉及信号量),我们可以认为可重入代码是线程安全代码的真子集,并非所有线程安全的代码都可重入
- 可重入代码特征:不依赖全局变量,存储在堆上的数据,系统共享资源等,不可调非重入的方法
- 2.线程本地存储概念:一段代码中的数据必须和其他代码共享,看看能不能把这些共享数据的代码只放在一个线程中执行,无序同步也能线程安全
符合上述特征的栗子有web服务端一个线程 对应一个 请求,如果某个变量只被一个线程共享,可以使用java.lang.ThreadLocal类处理
- 每个线程的Thread对象都有一个ThreadLocalMap对象
- 这个对象存储了以ThreadLocal.threadLocalHashCode为键,k-v为值的map
3、锁优化
高效并发是从JDK5升级到JDK6后一项重要改进项,HotSpot开发团队在这个版本花费大量资源完成锁优化
- 适应性自旋:不能一直自旋,限制自旋次数,虚拟机可以根据自旋次数成功次数动态的修改自旋临界值
- 锁消除
- 锁膨胀
- 轻量级锁
- 偏向锁
1、适应性自旋锁
- 不能一直自旋,限制自旋次数
- 虚拟机可以根据自旋次数成功次数动态的修改自旋临界值
2、锁消除
指的是虚拟机在编译器运行时,对一些代码要求同步,但是被检测到不需要同步(无共享数据或者不存在数据竞争)的代码将锁消除
- 锁消除主要判断依据来源于逃逸分析的数据支持
- 如果判断堆上的数据都不会逃逸出被其他线程访问到,那么就可以把他们当做栈上数据对待,认为他们是同步的无需加锁
之外,其他线程无法访问到它,虽然这里有锁,但是可以被安全的消除,在解释时这里仍会加锁,当时经过服务端编辑器即使编译之后,这段代码就会忽略所有的同步措施直接执行
3、锁粗化
4、轻量级锁
5、偏向锁
|