咋说呢,这个原本不在我的计划内,但是因为之前在看雷神的视频一直在弹幕看到阳哥,所以手一贱就去搜索了阳哥都讲了什么,就看到了这个2019java大厂面试题全集,仔细看了下目录还挺不错,而且主要这个教学视频是讲jvm和juc的,这两样jvm我是看过书,juc之前看过狂神的教学全集,所以也都算有基础,满足了基本学习的条件,当然了和视频第一节阳哥的扎心语录也有挺大关系,所以打算再抽出时间每天看一小时面试题的整理。只能说计划不如变化快吧。
下面开始正经的做笔记!
并发和并行
这个概念标准来说不算是面试题,但是一定要清晰的两个概念:并发是同时接收多个请求,比如秒杀。但是本质上这些请求还是要一个个来执行的。而并行就是同时执行多个任务,比如说一边烧水一边看电视,烧水和看电视的行为可以同时发生。
atomic原子
atomic就是原子的意思,它是jdk中的一个原子类的总包,我们都直到int i = 0;i++.这个i++虽然只有一行代码,但是并不是原子性的,因为其实它的底层分了三句执行。而atomic包下的AtomicInteger确是一个原子类,同样的其他的数据类型Double也是如此。
volatile的理解
volatile是java虚拟机提供的轻量级的同步机制,其主要三大特性:
- 保证可见性
- 不保证原子性
- 禁止指令重排
这三点都挺好背的,但是为什么有这三个特性,每一个特性都可以单独的解释一波。不过阳哥的视频中没有直接解释这三点,而是引申除了JMM,所以我这里都会按照视频来记录,可能看起来有点乱,毕竟知识点杂,而且细节很多。不过我个人感觉说的都是干货,所以建议大家还是耐下心来跟着走。
先说下电脑中三个地方的速度:
硬盘<内存
而我们在买电脑的时候所谓的8G或者6G,这个就是所谓的主内存。在多线程的情况下,每一个线程都有一个工作内存。在使用变量的时候,是把主内存中的变量copy一份到工作内存中的,所以这就是JMM的模型图(这个是一个抽象的概念哟~!并不真实存在的。它描述的是一组规则或规范,通过这组规范定义了程序中的各个变量的访问方式):
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新会主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
简单来讲:变量的放在主内存中,所有线程共享的,但是当线程要使用变量的时候是从主内存获取变量的拷贝副本放在工作内存中使用,然后使用完后返回给主内存。重点是如果在使用的时候改变了变量的值,那么返回主内存的时候会修改这个变量的值,可是这个时候其他的线程是不知道的。
也就是说,A,B同时从主内存拿到x=1的拷贝副本。这个时候A用完了并且把x的值改为2了,可是这时候B中x还是1。
而volatile的可见性就是在这里起作用:当x的值修改的时候,所有拿到这个值的线程都会收到这个消息:x的值更改了。这样其他的线程就会去主内存重新获取这个值了。
而JMM的要求是:
- 可见性
- 原子性
- 有序性
而我们说volatile是java虚拟机提供的轻量级的同步机制的原因是它做不到JMM的全部要求,它只满足1(保证可见性),3(禁止指令重排).而不能保证原子性。
其实可见性和原子性都比较好理解,这里重点说一下指令重排:
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。一般分以下三种:
源代码->编译器优化的重排->指令并行的重排->内存系统的重排->最终执行的命令
简单来说,在单线程的环境里确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性。
而多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保持一致性是无法确定的,所以结果无法预测。
用实际的例子来理解就是如下图:
其实很多时候我们实际使用volatile不是为了可见,而是单纯的想要禁止指令重排。毕竟这个指令重排在多线程中是很不可预测的情况。可能跑一万次就出现一次奇怪的现象,但是我们又不能单纯的无视这种情况。
为什么volatile会禁止指令重排呢?
先了解一个概念:内存屏障(Memory Barrier)。又称内存栅栏,是以一个cpu指令。它的作用有两个:
- 保证特定操作的执行顺序。
- 保证某些变量的内存可见性(volatile也是利用该特性实现的)
由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条Memory Barrier则会告诉编译器和cpu不管什么指令都不能这条Memory Barrier指令重排序。也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障的另一个作用是强制刷出各种cpu的缓存数据,因此任何cpu上的线程都能读取到这些数据的最新版本。
在哪些情况下用volatile?
这里简单说一个概念:DCL模式(double check lock).
双重检测锁模式:其实就是一种代码书写模式,如其名,可以理解为在加锁之前和加锁之后都进行一次判断。下面是简单的实现代码:
public class Test5 {
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(()->{
Test6.getInstance();
}).start();
}
}
}
class Test6{
private static Test6 test6 = null;
private Test6() {
System.out.println("进入私有构造方法!");
}
public static Test6 getInstance() {
if(test6 == null) {
synchronized (Test6.class) {
if(test6 == null) {
test6 = new Test6();
}
}
}
return test6;
}
}
其实关键的代码就是我红色框起来的地方:为了保持单例模式而书写的:双重检测锁模式(锁前锁后都检测一遍)
正常来讲这样写代码就可以了,但是别忘了因为指令重排,所以还是有一定的不确定性,别管是百万分之一还是千万分之一,所以为了解决这个问题,我们要禁止指令重排。用法很简单:在单例模式前面加个volatile修饰。如下图:
CAS
什么是CAS?
CAS其实是比较并交换。是CompareAndSwap的缩写。
其实本质就是有两个参数:期望值和修改值。当期望值和实际值相同,那么把当前值改成修改值。但是如果期望值和实际不同则不修改。
谈谈unsafe
其实所有的jdk自带的原子类底层都是unsafe操作而不是我们常用的synchronized。如下源码截图:
Unsafe类是CAS的核心类,由于java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于java留的一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,而java中CAS操作的执行依赖于Unsafe类的方法。
我们点进Unsafe类会发现这里的方法都是native方法。也就是说Unsafe类中的方法都是直接调用操作系统底层资源执行相应任务。
简单总结下:CAS(Compare and swap)是一条cpu并发原语。它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子性的。
CAS并发原语体现在java语言中就是sun.misc.Unsafe类中的各个方法,调用Unsafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调:由于CAS是一种系统原语,原语属于操作系统用语范围,是由若干条指令组成的,用于完成某个功能的一个过程。并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条cpu的原子指令,不会造成所谓的数据不一致问题。
下面附上cas中++的源码:
其实这个代码不算难,简单说下:
v1是对象本身
v2是引用地址
v5是通过v1,v2获取的这个对象当前实际值。
v4是要加的值。
首先我们获取这个对象的值作为预期值,然后在while的判断条件中比较:如果预期值是实际值,那么执行增加的操作,并且返回true,因为while条件是取反,所以真正执行增加了while中是!true,也就是false,这样就跳出循环了,然后返回。
但是如果在while中比较发现预期值和实际值不同,可能是有别的线程在这期间修改值了,所以这个CompareAndSwapInt方法会返回false并且不修改值。如果这个方法返回false那么while条件是!false,也就是true,则再次进入循环体中,重新获取这个值。直到修改成功跳出循环。
其实这种代码的书写本质就是自旋!
上述代码其实就是比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存中的值一致为止。
CAS的缺点:
- 循环时间长,开销比较大(do{}while()语句自旋,可能卡的比较久,给cpu带来很大的开销)
- 只能保证一个共享变量的原子性(synchronized是代码块,而这个CAS只是一个变量)
- 引出ABA问题
什么是ABA问题?
其实这个比较简单,就是我们知道这个CAS是比较并替换。而比较的是字面值。那么问题就来了!开始A结束A,但是中间加塞了个B,本质上结束的A和开始的A已经不一样了!
- 比如一个原子类,初始值的A.
- 这个时候多个线程来了,分别把初始值拿走了,其中线程x预期A,改成B,线程y预期A,改成C。
- x先执行了,当前值是B了,这个时候另一个线程z来了:期待是B,改成A。
- z的判断是对的,所以当前值被z线程改成A了。
- 这个时候y线程才进来,一看期望是A是对的,所以直接改成了C。
看似这个操作是顺序过来的,很和谐,但是本质上!!y线程期望的虽然是A,但是应该是最开始的那个A,而不是被换了一圈回来的A。
举一个现实中的例子:男A,男B同时像一个单身女生求婚。 女生答应了男A,结果一段时间两个人又离婚了。虽然当前女生还是单身,看似和男B像女生求婚的时候情况一样,但是!这个时候女生答应男B的话男B会不会介意?用一句言情点的话说:不一样了,回不去了!
而解决这个问题的办法就是加个版本号或者时间戳!
就是每次更换记录个时间戳(也可以是版本号递增)。然后更换的时候不仅仅比较当前值,还要比较这个版本号(时间戳)。
当然了,我这个里要额外说两句:关于ABA问题,要酌情考虑介不介意!比如我上面那个例子,结婚离婚结婚离婚,可能对于追求者来说就很介意,所以一定不能让ABA问题发生。但是有些情况是无所谓的,比如说余额。不管是花了赚赚了花,加加减减无所谓,都已账户上的当前余额为准就行了。所以A,B,A什么的无所谓的。
而我们上面提到的那个类AtomicInteger就是无所谓ABA问题的类。只要值相同就行,无所谓中间经历了什么。
但是有些不想让ABA问题发生,这个时候可以引用的类JDK也给写好了,就是 AtomicStampedReference类。
用起来的话也比较简单,和AtomicInteger类似。就多了一个初始标记参数:
我们看第一个构造器,两个参数那个,除了一个v就是初始值,还有一个stamp,英文翻译是邮票,其实就是我们之前说的标记
下面是一个两种原子类的demo:
这个第三次修改的时候,因为版本号不符合所以修改失败了。
所以说加版本号就是解决ABA问题的方法!
本篇笔记就整理到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利!