前言:在慕课网上学习剑指Java面试-Offer直通车时所做的笔记,供本人复习之用.
目录
第一章 Java内存模型
第二章 JMM中的主内存和工作内存
2.1 主内存与工作内存介绍
2.2 JMM与JVM的区别
2.3 可见性问题
2.4 指令重排序需要满足的条件
2.5 happens-before原则
2.5.1 例1
2.5.2 volatile
2.5.3 例2
2.5.4 例3
2.5.5 例4
第三章 CAS(Compare and Swap)
3.1 AtomicInteger
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存,有些地方成为栈空间,用于存储线程私有的数据,而java内存模型中规定,所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问,线程对变量的操作如读取,赋值等必须在工作内存中进行,首先将变量从主内存拷贝到自己的工作内存中,然后对变量操作,操作后再将变量写回主内存.不能直接操作主内存中的变量,工作内存中存储着主内存中变量的副本拷贝,工作内存是每个变量的私有区域,因此不同线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成.
JMM中的主内存:
JMM中的工作内存:
注意第一条,即使两个线程执行的是同一个方法,它们也会在自身的工作内存中创建当前线程的工作变量.
JMM与JVM内存区域划分是不同的概念层次.
JMM描述的是一组规则,通过这组规则,控制程序中各个变量在共享数据区域和私有数据区域的访问方式,jmm是围绕原子性,有序性,可见性展开的.
相似点:存在共享区域和私有区域.
JMM中主内存是共享数据区域,应该包括堆和方法区,而工作内存数据线程私有数据区域某个程度上讲,应该包括程序计数器,虚拟机栈,以及本地方法栈.
对于一个实例中成员方法而言,如果方法中包含的本地变量是基本数据类型的(那8种),这些本地变量将直接存储在工作内存的栈帧结构中.
倘若本地变量是引用类型的,该变量的引用会存储在工作内存的栈帧中,而对象实例则存储在主内存.即我们前面说的共享区域堆当中.
对于b实例对象的成员变量,static变量,类信息均会被存储在主内存的堆区.
主内存的实例对象可以被多线程共享,倘若两个线程调用了同一个对象的同一个方法,两个线程会将要操作的数据拷贝一份到自己的工作内存中,对数据操作完成后刷新到主内存中.
把数据从内存加载到缓存寄存器,运算结束,写回主内存.
但是当线程共享变量的时候,情况就变得非常复杂了,如果处理器对某个变量进行了修改,可能只是体现在该内核的缓存里,而运行在其它内核上的线程可能加载的是旧状态,这很可能导致一致性的问题,从理论上来说,多线程共享引入了复杂的数据依赖性问题,不管处理器,编译器怎么做重排序都必须尊重数据依赖型的要求,否则就打破了数据的正确性.这就是jmm所要解决的问题.
在执行程序的时候,为了提高性能,处理器和编译器常常会对指令进行重排序,需要满足以下条件:
以上两点可以归结为一点:
jmm内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式提供内存可见性保证,也就是实现了各种happens-before的规则,更多的复杂度在于,需要尽量确保各种编译器,各种体系结构的处理器,能够提供一致的行为.
在jmm中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须满足happens-before的关系.
happens-before原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依据,依靠此原则我们变能解决在并发环境下两个操作存在冲突的问题.
我们分析以下代码,假设线程A happens-before线程B,即线程A先于线程B发生,可以确定线程B操作后j是1是确定的,如果它们不存在happens-before原则,那么j=1就不一定能够成立.
happens-before的八大原则:
我们约定线程A执行write操作,线程B执行read操作,且线程A优先于线程B去执行,那么线程B获得的结果是什么呢?
可以看到5,6,7,8这四个规则是可以被忽略的,因为与这段代码毫无关系.
两个线程,规则1不适用,没有锁,规则2不适用,规则3肯定也不适用,没使用volatile,规则4也不适合.
所以无法通过happens-before原则推导出A happens-before B,不知道B什么时候执行.
所以这段代码不是线程安全的.
我们只需满足2,3规则中的一个即可保证线程安全,即加同步锁或者volatile.
volatile:JVM提供的轻量级同步机制
volatile的作用:
volatile的可见性:我们必须意识到volatile修饰的变量总是对所有线程立即可见的,对volatile的所有写操作总是能立即反映到其它线程中,但是对于volatile运算操作在多线程环境中并不保证安全性.
volatile变量为何立即可见?
volatile如何禁止重排优化?
首先要了解内存屏障:
volatile正是通过内存屏障实现其在内存中的语义即可见性和禁止重排优化.
volatile和synchronized的区别:
value变量的任何改变都会反映到线程中,但是若有多条线程同时访问increase方法,就会出现线程安全问题,毕竟value++操作并不具备原子性.
value++操作是先读取值,然后再写回一个新值,相当于原来的值加1分两步来完成.如果第二个线程在第一个线程读取旧值写回新值之间,读取value的值,那么第二个线程就会与第一个线程一起看到同一个值.并执行相同的加1操作,引发了线程安全问题.所以对于increase必须用synchronized修饰,以便保证线程安全,需要补充并且注意的是,synchronized关键字解决的是执行控制的问题,它会阻止其它线程获取当前对象的监控锁,这就使被synchronized保护的代码块无法被其它线程访问,也就无法并发执行.
修改线程安全:
synchronized会创建一个内存屏障指令,其保证了所有CPU结果都会直接刷到主存中,从而保证了操作的内存可见性.也保证了顺序执行.
由于对boolen的修改属于原子操作,因此可以使volatile修饰该变量,使其修改对其它线程立即可见,从而达到线程安全的目的.
面试时经常要写的所谓实现线程安全的单例写法,通过引入synchronized代码块试图解决多线程请求单例时重复创建单例的隐患.下面的代码在多线程环境下依然会有隐患.
原因:
new singleton()创建时会有三步
并可以有如下的重排序优化.这样就可能导致getInstance返回null,一个线程走到了第二步,memory还是空,另一个线程判断instance不是null,直接返回memory,造成错误.
解决方法如下,使用volatile使instance禁止指令进行重排序即可,即2,和3不能颠倒过来.
像synchronized这种独占锁属于悲观锁,悲观锁始终假定,因此会屏蔽一切可能违反数据完整性的操作,除此之外,还有乐观锁,它假定不会发生并发冲突,因此只在提交操作时检查是否违反数据完整性,如果提交失败则会进行重试,而乐观锁最常见的就是CAS.
CAS是一种高效实现线程安全性的方法.
CAS思想:
包含三个操作数-内存位置(V),预期原值(A)和新值(B)
将内存位置的值与预期原值进行比较,如果相匹配则处理器会自动将内存位置的值更新为新值,否则处理器不做任何操作,这里内存位置的值V即主内存的值.
举个例子,当一个线程需要修改共享变量的值,完成这个操作先取出共享变量的值赋给A,基于A的基础进行计算得到新值B,执行完毕需要更新共享变量的值的时候,我们调用CAS方法去更新共享变量的值.
看一下之前的例子:
查看其字节码:
可以看到value++被拆分成了如下的指令,首先需要getfield拿到原始的value,也就是从我们的主内存中将value加载进当前线程的工作内存中,执行iadd进行+1的操作,之后再执行putfield把累加后的值写回我们的主内存当中.
通过volatile修饰的变量,可以保证线程之间的可见性,同时也不允许JVM对它们进行重排序.但是并不能保证这三个指令的原子执行,在多线程并发下,无法做到线程安全.
该如何解决呢?
在add前加入synchronized操作即可解决.
但是能否尽量提升性能呢?
可以使用AtomicInteger来满足需求,其位于concurrent.atomic包中,
从AtomicInteger的内部属性可以看出,它依赖于unsafe提供的一些底层能力,进行底层操作,以volatile的value字段记录数值以保证可见性.
其中的getAndIncrement方法可以解决上面value++的不安全性.此方法会利用value字段的地址偏移直接完成操作.
点进getAndIncrement,因为需要返回数值多以需要添加失败重试的逻辑.
而向返回布尔类型的,因为其返回值表现得就是成功与否,所以不需要进行重试.
Unsafe里的这些方法,如compareAndSetInt 则实现了CAS.
CAS多数情况下对开发者来说是透明的,我们更多的是使用并发包间接享受到lock-free机制在扩展性上的好处.
CAS缺点: