Java内存模型
为屏蔽各个硬件底层内存模型,设计JMM模型。定义程序中各个变量的访问规则,即把变量存储到内存以及从内存中取出的底层细节(对应硬件模型的理解:从内存中取出数据到缓存,后到寄存器参与计算,以及从缓存刷入到内存的时机)。此处变量为线程共享资源,不包括线程私有,如局部变量和方法参数。为了效率,JMM没有限制执行引擎使用寄存器和缓存与内存进行交互(没有规定一定要刷到内存,单线程没有问题,但是多线程就会存在并发问题),也没有限制即时编译器对代码执行顺序进行优化(只要保证单线程情况下代码执行正确即可,比如不用立马将缓存内容写入内存,即使你这样写了代码)。
工作内存和主内存对应硬件中的缓存、寄存器和内存。线程中工作内存会保存了线程使用到的变量的主内存的副本(对于对象,访问到的某个字段),对于volitail变量也不例外。
工作内存与主内存的交互:
read load store write读主内存到工作内存,写入工作内存 保存工作内存到主内存 将得到的工作内存变量写入主内存
这个lock不是并发包里面的,是缓存锁的意思。lock锁主内存,不过他会清空工作内存中的此变量(不然别的线程继续访问自己的工作内存,跟没锁一样);unlock解锁之前必将变量同步回主内存,即store write操作。对应cas操作,set也就将变量同步为主内存了。
这样操作保证可见性,思想上对应宏观的java语言层面synchronized或者lock包里的操作,happens-before原则,加锁操作的变量对后续使用此锁的线程必是可见的,操作发生有先后顺序。
volitail变量的特殊规则
volitail变量是前面加了个lock()
总线锁或者缓存锁实现:缓存锁依靠缓存一致性,实现了可见性?:
M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改。
S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致。
I(Invalid) 表示缓存已经失效。
CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状 态 CPU 只能从主存中读取数据。
CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状 态的写,需要将其他 CPU 中缓存行置为无效才可写
缓存一致性协议优化后存在的问题:
各个cpu缓存行的状态是通过消息机制来传递的,如果cpu0对一个缓存中共享的变量做修改的话,首先需要发送一个失效消息给其他缓存该数据的cpu,并且要等到他们的确认回执。cpu0在这段时间处理阻塞状态,为了避免cpu资源浪费,则引入一个storeBfferes.
可能出现指令重排序问题,第七步可能还没执行?,volitail第二个语义,禁止指令重排序,通过内存屏障实现。
此外,语义增强,普通变量代码也禁止指令重排序(Java并发编程艺术)happens-before volitail写在volitail读之前,避免了单线程指令重拍没问题但是多线程指令执行顺序交错而导致的并发问题
可见性:其在工作内存中可以不一致,但是由于每次使用需要刷新,执行引擎看不见不一致的情况,可是在执行最简单i++这种任务也有将数据加载到操作数栈,此时读工作内存确实是正确的,java并发编程艺术将volitail变量的get和set理解为加锁操作。但是之后执行加一的操作可能就很多步了,此时别的线程早已操作此volitail变量并且写回了主内存,待这条线程完成工作写会时便会出现小值覆盖大值或者重复操作的情况。只能保证可见性,不保证原子性,所以配合cas使用,先读一个volitail将其视为旧值,cas再读一次也就是最新的值,比较一下,一样就说明没问题,cas为原子操作,不存在比较后别的线程又改了volitail变量的值。
线程
java线程状态 操作系统线程状态
- new 新建
- runable(操作系统running ready)运行(可能在执行也可能在等cpu时间片)
- waiting无限期等待,不会被cpu分配执行时间,等待其他线程调用notify之类的唤醒,在等待队列
调用wait join park方法进入 比如condition.await 可以看做是条件锁,不是别人放了锁你就能去抢,需要满足condition,拿到锁的会调用相应的方法唤醒waiting线程,等锁释放了一起抢,变成runnable状态,没抢到blocked,继续等释放锁,抢到后如果发现定义的条件不满足了,又会调用await,回到等待队列
join本质上是主线程调用了wait,另一个线程完成后,jvm自动调用notifyall
park 线程有一个计数器,初始值为0,调用park,将线程状态改为waiting。如果值为1,改为0,其余什么也不做;调用unpark,值改为1 - Timed waiting限期等待 不会无限期等待其他线程唤醒,到时了系统自动唤醒
- blocked阻塞 synchronized在同步队列,在等待获取一个排他锁,在另外一个线程release锁的时候,tryacquire锁,变成runnable,没抢到锁的再blocked
- Terminated结束 线程运行结束
调用jdk的lock接口中的lock,线程处于wait,而不是blocked,因为jdk的底层是基于AQS的,AQS底层是park、unpark挂起唤醒线程的
调用阻塞io,比如socket编程时,调用accept或者read,线程处于runnable状态,但实际上线程不会被运行,因为操作系统对应的线程处于阻塞态,只有当io就绪时,才会变成就绪态,有机会运行。为什么处于runnable状态,是因为jvm认为等磁盘io和等cpu执行权是一样的
Java语言中的线程安全
-
不可变,final(没有发生this引用逃逸的情况:构造器把this引用传递出去,别的线程可能通过这个引用访问到初始化一半的对象),永远不会看见他在多线程不一致的情况,比如String、Integer这些声明final的不可变对象,修改只是给你返回一个新构造的对象
关于final变量初始化的一些事 (1)由于JVM的指令重排序存在,实例变量i的初始化被安排到构造器外(final可见性保证是final变量规定在构造器中完成的);
(2)类似于this逃逸,线程A中构造器构造还未完全完成。 -
绝对线程安全:vector是个线程安全的容器,他的方法都是被synchronized修饰的,没人敢说他不安全,只是效率太低,所有修改、甚至读都需要加锁(如果读没有加锁,很有可能读的时候,别的进程进行了修改,导致访问角标越界)
-
相对线程安全:单个方法调用是线程安全的,但是对于一些特定顺序的连续调用,需要加同步手段以保证线程安全
-
线程兼容 hashmap arraylist 需要加同步手段以保证线程安全
-
线程对立 suspend resume 同步了也可能死锁,已经废弃了
线程安全的实现方法
- 互斥同步:临界区、互斥量mutex、信号量semaphore
最基本的互斥手段是synchronized,其实现方式通过编译后的字节码可见是monitorenter和monitorexit,如果synchronized指名了reference对象,那就用他来加锁解锁。如果没有,看情况是用实例对象还是类对象(修饰的是实例方法还是类方法)。执行monitorenter时通过cas修改锁的计数器(state),加一说明加锁,释放对应的减1(不需要cas的原因是此对象此时加锁线程可以执行)。如果获取锁失败了,当前线程就会阻塞等待,作为节点加入同步队列,等到锁被释放。因为java的线程是映射到操作系统的线程,而不是用户线程,所以线程的切换(阻塞唤醒)需要操作系统进行,用户态到内核态的切换可能还没完成,锁已经释放了,阻塞的不一定合理,可以在阻塞之前加入一段自旋过程,避免频繁切换。
相较于synchronized,reentrantlock(重入锁)的lock多了一些功能:等待可中断(当前持有锁的线程长期不释放锁,等待的线程可以放弃等待,改为做其他事情);公平锁(加上一个判断前一个节点是不是头结点,以此按照同步队列顺序进行执行);锁可以绑定多个condition,创建多个等待队列 - 非阻塞同步
互斥同步可以看到是一种悲观的并发策略,无论是否出现竞争,都去加锁,当然虚拟机会优化很多不必要的加锁
乐观的并发策略,先执行,如果没有发现其他线程争用共享数据,执行成功;如果冲突,有相应的补偿措施,常见的自旋手段,由于没有线程被挂起,称为非阻塞同步。这是需要硬件支持的,操作和冲突检测具有原子性才行,如果检测完没问题,切换到另一个线程进行了操作,此时回到此线程执行就会出现问题,所以二者不应该分割
unsafe的cas操作,内存位置(变量的内存地址V),获取到的旧值A,新值B,如果V符合A,更新为B,否则自旋,重新获取A,再来cas - 无同步方案
可重入代码:不依赖堆上的数据和公用的系统资源,纯代码Pure code,天生不共享数据
线程本地存储:threadlocal 每一个thread线程对象,都有一个threadlocalmaps对象,这个对象存储了kv键值对,threadlocal对象为键,本地线程变量为值
锁优化
- 自旋锁和自适应自旋
不要直接进行线程的挂起以及恢复,先自旋一段时间,看是不是很快就会释放这个锁。不能代替阻塞,时间长了还是要阻塞
自适应自旋,自旋次数由虚拟机根据监控进行合理调整 - 锁消除
检测到不可能存在贡献数据时将此锁进行消除。检测判断依据:逃逸分析。
如果判断在这段代码中,堆上的数据都不会逃逸被其他线程访问到,那么可以把他们当作栈上的数据分析,认为他们是线程私有的,而不需要同步加锁 - 锁粗化
频繁反复对同一个对象加锁解锁,比如连续调用某个同步方法,会将其同步的范围扩展,也就是粗化的概念,这样加锁一次就好了 - 轻量级锁
传统锁的实现是依靠操作系统互斥量实现的,也就是mutex,没必要上来就这么狠,使用轻量级锁降低性能损耗
对象头的内存布局(毕竟用锁的时候,锁的是对象,这些信息就记录在对象头):
对象头信息与对象自身定义的数据信息无关, 分为两部分信息,第一部分是自身运行数据Mark word,有hashcode,GC分代年龄等,是实现轻量级锁和偏向锁的关键。另外一部分存储指向方法区对象类型数据的指针,如果是数组的对象的话,还有额外的部分用于存储数组长度。
在代码进入同步块的时候,如果对象没被锁定(锁标志位01),在当前线程的栈帧中建立“锁记录”,用于存储mark word的拷贝,称之为displaced mark word。然后用cas尝试将mark word更新为指向displaced mark word的指针,如果更新成功,即拥有此锁,并将对象mark word的锁标志位更新为00,表示轻量级锁指针。如果两条以上竞争,膨胀为重量级锁。 - 偏向锁
锁对象第一次被线程获取的时候,虚拟机将对象头的标志位设为01,偏向模式。同时使用cas操作把获取到这个锁的线程的id记录在对象的mark word中。如果cas成功,以后持有偏向锁的进程进出不需要同步操作。当另一个线程尝试获取时,偏向模式宣告结束,变为轻量级锁