文章目录
- 1. 谈谈volatile的理解
- 1.1 什么是JMM
- 1.1.1 可见性
- 1.1.2 原子性
- 1.1.3 禁止指令重排
- 1.2 工作哪用到指令重排
- 2. 如何理解CAS
- 2.1 是什么
- 2.2 为什么可以保证原子性
- 2.3 CAS缺点
- 2.4 ABA问题与底层原子引用及如何解决
- 1 ABA问题是什么?
- 2 原子引用
- 3. 如何解决ABA问题
- 3. ArrayList是线程不安全的,编码写出不安全案例并给出解决方法
- 4. 锁的理解
- 1 公平锁、非公平锁
- 2 可重入锁
- 3 自旋锁
- 4 独占锁(写锁)/共享锁(读锁)
- 5 synchronized和lock有什么区别?用lock有什么好处?
- 6 Juc中常见的类CountDownLatch、CyclicBarrier、Semaphore的理解?
- 1. CountDownLatch
- 2. CyclicBarrier
- 3. Semaphore
- 7. 谈谈阻塞队列
- 8. Callable接口理解
- 9. 线程池用过吗?ThreadPoolExecutor谈谈你的理解?
- 9.1 为什么用线程池
- 9.2 线程池如何使用?
- 9.3 线程池重要参数讲解
- 9.4 底层工作原理
1. 谈谈volatile的理解
volatile是java虚拟机提供的轻量级同步机制,主要特点有三个:
- 保证线程之间的可见性
- 禁止指令重排
- 不保证原子性
谈到volatile关键字,就不得不首先提到java的内存模型(JMM)
1.1 什么是JMM
JMM是java提供的一种抽象模型,主要是通过一组规范来定义程序中的变量的访问方式。(包括实例字段、静态字段等),JMM主要用于线程间的同步,主要做了如下规定:
- 线程解锁前,必须把共享变量刷新到主内存。
- 线程加锁之前,必须读取主内存的最新值到工作内存。
- 加锁、解锁都是操作的同一把锁。
上面提到了主内存和工作内存又是什么东西呢?
JVM运行程序的实体都是线程,每次创建线程的时候,JVM都会给线程创建属于自己的工作内存,注意工作内存是该线程独有的,也就说别的线程无法访问工作内存中的信息。而Java内存模型中规定所有的变量都存储在主内存中,主内存是多个线程共享的区域,线程对变量的操作(读写)必须在工作内存中进行。
比如:如果要操作一个变量,首先需要将变量从主内存中拷贝到自己的工作内存,然后对变量操作,操作完成之后再写会主内存,而不能直接操作主内存中的变量。各个线程中的工作内存存储的都是主内存变量的副本。
上面这种更新或者说读取变量的方式在单线程下不存在问题,但是在多线程下呢?多个线程工作去读取或修改一个变量,如何保证每个线程都能够读取到正确的值呢?
设想一下:存在两个线程A、B,同时从主线程中获取一个对象(i = 25),某一刻,A、B的工作线程中i都是25,A效率比较高,片刻,改完之后,马上将i更新到了主内存,但是此时B是完全没有办法i发生了变化,仍然用i做一些操作。问题就发生了,B线程没有办法马上感知到变量的变化!!
1.1.1 可见性
Volatile就是解决该问题的,保证A线程修改值之后,B线程能够知道参数已经修改了,这就是线程间的可见性。 A修改共享变量i之后,B马上可以感知到该变量的修改。
可见性demo:
第一次number没有加上volatile关键字
结果如下:
加上volatile关键字之后的执行结果:
1.1.2 原子性
JMM的目的是解决原子性,但volatile不保证原子性。如何保证原子性,下文再继续讲解。
为什么volatile无法保证原子性呢?
因为上述的Java的内存模型的存在,修改一个i的值并不是一步操作,过程可以分为三步:
- 从主内存中读取值,加载到工作内存
- 工作内存中对i进行自增
- 自增完成之后再写回主内存。
每个线程获取主内存中的值修改,然后再写回主内存,多个线程执行的时候,存在很多情况的写值的覆盖。
用下面的例子测试volatile是否保证原子性。
结果如下:
Result = 1906
Process finished with exit code 0
接回上面的问题,如何保证原子性呢?
Juc下面提供了多种方式,比较轻量级的有Atomic类的变量,更重量级的有Synchronized关键字修饰,下面用Atomic来保证原子性的测试:
输出结果如下:
Result = 2000
Process finished with exit code 0
1.1.3 禁止指令重排
计算机在底层执行程序的时候,为了提高效率,经常会对指令做重排序,一般重排序分为三种
- 编译器优化的重排序
- 指令并行的重排
- 内存系统的重排
单线程下,无论怎么样重排序,最后执行的结果都一致的,并且指令重排遵循基本的数据依赖原则,数据需要先声明再计算;多线程下,线程交替执行,由于编译器存在优化重排,两个线程中使用的变量能够保证一致性是无法确定的,结果无法预测。
volatile可以防止指令重排情况。
禁止指令重排底层原理:
利用内存屏障来实现,通过插入内存屏障禁止在内存屏障前后的指令执行重排序的优化。内存屏障主要有两个功能:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性。
Volatile与内存屏障又是如何起着作用的呢?
对于Volatile变量进行写操作时,会在写操作后面加上一个store屏障指令,将工作内存中的共享变量值即可刷新到主内存;
对于Volatile变量进行读操作时,会在读操作前面加入一个load屏障指令,读取之前马上读取主内存中的数据。
1.2 工作哪用到指令重排
单例模式在多线程的安全性保证。
在查看多线程之前,首先来回归一下单线程下的单例模式:
输出结果如下:
构造方法
true
懒汉模式的单例:
输出结果:
true
但是如果在多线程下呢?
构造方法
构造方法
构造方法
构造方法
奇怪的是,构造方法竟然执行了四次,说明并不是真正的new一个对象,并不是真的线程安全的*(当然每个人测试的结果是不一样的)*
如何解决上述问题呢?
- 在
getInstance()
方法中加上Synchronized
关键字来保证线程安全。 - 线程安全的双重检查来保证。(在对象的加锁前后都加上非空检查)
双重检查的单例模式:
单例这种双重检查的近线程安全的单例模式也有可能出现问题,因为底层存在指令重排,检查的顺序可能发生了变化,可能会发生读取到的instance !=null,但是instance的引用对象可能没有完成初始化。,导致另一个线程读取到了还没有初始化的结果。
为什么会发生以上的情况呢?得来分析一个对象的初始化过程。
第五步初始化过程会分为三步完成:
- 分配对象内存空间
memory = allocate()
- 初始化对象
instance(memory)
- 设置instance指向刚分配的内存地址,此时
instance = memory
再使用该初始化完成的对象,似乎一起看起来是那么美好,但是计算机底层编译器想着让你加速,则可能会自作聪明的将第三步和第二步调整顺序(重排序),优化成了
这种优化在单线程下还不要紧,因为第一次访问该对象一定是在这三步完成之后,但是多线程之间存在如此多的的竞争,如果有另一个线程在重排序之后的3后面访问了该对象则有问题了,因为该对象根本就完全初始化的。具体可以看看下图:
但是上述问题在单线程下不存在该问题,只有涉及到多线程下才会发生。
为了解决该问题可以从两个角度解决问题,
1. 不允许2和3进行重排序
2. 允许2和3重排序,但是不允许其他线程看到这个重排序。
因此可以加上Volatile关键字防止指令重排。
2. 如何理解CAS
2.1 是什么
书面上的话:比较并交换(Compare And Swap),主要是为了解决原子性问题,同时又不想利用重量级的锁。
通俗的话说:如果想更新一个值,想看看该值是否等于某个值,如果等于则更新该值。
下面看一个简单的例子:
其中Atomic
类是使用CAS思想最广泛的类,具体的思想和上面一样,源码此处先不分析。
输出结果如下:
true
11
false
11
2.2 为什么可以保证原子性
底层源码:
CAS本身通过本地(native)方法来调用,该类可以直接操作底层内存的数据,Unsafe中所有的方法都是用native修饰,可以直接调用操作系统底层资源执行任务。
其中变量valueOffset
表示该对象值在内存中的偏移地址,用来寻找数据的地址。
在底层判断内存中某个位置的值是否为预期值、如果是则更新为新的值,该比较和赋值动作是原子的。是完全依赖于硬件的功能,通过底层硬件实现原子的功能,该过程的执行是不允许中断的,不会造成所谓的数据不一致问题。
2.3 CAS缺点
- 循环开销大:如果比较的时候一直不等于预期值,则会一直循环等待,直到比较成功为止,该过程会给CPU带来较大开销。
- 只能保证一个共享变量的原子操作。对于多个共享变量无法保证原子性,因为每次比较的都是一个元素。
- ABA问题。
2.4 ABA问题与底层原子引用及如何解决
1 ABA问题是什么?
比如数值i = 5
,A线程本来想改成10,在更改之前,B线程先将i先由5变成了6,再更新成5,但是A线程并不知道i
发生过改变,仍然将i改成10。尽管最后的结果没有问题,但是整个过程还是不对的。
2 原子引用
将需要修改的一系列值保证成一个类,每次不是比较值,而是比较一个引用的整体,然后每次保证引用的原子性。
3. 如何解决ABA问题
一般都是利用时间戳作为版本,保证每次修改的值都是独一无二的。
可以将元素与版本号构成一个对象,每次保证引用的原子性。
3. ArrayList是线程不安全的,编码写出不安全案例并给出解决方法
HashSet及HashMap同理
案例
现象如下:
结果会报异常:
java.util.ConcurrentModificationException 并发修改的异常
为什么会产生这种问题呢?
因为在多线程下,每次往容器中写数据时,不保证顺序,谁抢占到了容器谁开始写入数据,因此可能存在覆盖情况,导致每次执行的结果都不一致。
如何解决这种问题呢?
- Vector集合类。但是该类在方法上加上Synchronize关键字,保证线程安全。不推荐,重量级,效率低。
-
Collections.synchronizedList
保证线程安全。
- 利用写时复制的集合类。
CopyOnWriteArrayList
写时复制类似于将读数据和写数据过程分离开来。
A线程和B线程都开始写数据,A、B每次写数据之前,都需要拿到一个许可证(类似于锁),主内存中数据复制到工作内存中,然后再进行修改,修改完毕之后将容器的引用指向新的数据集,然后再允许别的线程修改。
4. 锁的理解
公平锁、非公平锁、可重入锁(递归锁)、自旋锁,并手写一个自旋锁。
1 公平锁、非公平锁
抢占资源的顺序按照先后顺序依次获取,保证公平性,但是损失了效率。
非公平锁:抢占式的获取资源,哪个线程抢到资源哪个开始执行,也有可能存在一个线程一直获得资源,提高了效率。Synchronized是非公平锁。
上述只是简单的说公平锁的含义,具体的底层内容详见:JUC—ReentrantLock核心知识讲解
2 可重入锁
又称为递归锁:同一线程外层函数获取锁之后,内部函数仍然可以获取锁对象,线程可以进入任何一个已经拥有的锁所同步的代码块。
ReentrantLock和Synchronized两个都是典型的可重入锁。
可重入锁最大的作用可以避免死锁
3 自旋锁
尝试获取锁的线程不会阻塞,而是采用循环的方式尝试获取锁,可以减少线程上下文切换的消耗,但是循环也比较消耗CPU。
自旋锁实现:
4 独占锁(写锁)/共享锁(读锁)
独占锁:该锁对象一次只能被一个线程持有。上面说的ReentrantLock
及Synchronized
都是独占锁。
共享锁:一个锁对象可以被多个线程持有。ReentrantReadWriteLock
该锁读的时候是共享锁,写的时候是独占锁。
5 synchronized和lock有什么区别?用lock有什么好处?
-
synchronized
是java
的关键字,是JVM
层面控制(底层是monitor
实现的),lock
是具体类的api
层面的锁(java.util.concurrent.locks.Lock
)。 -
synchronized
不需要用户手动释放锁,当synchronized
代码执行完毕系统会自动让线程释放对锁的占有。ReentrantLock
则需要用户去手动释放锁,如果不手动释放锁,则可能会造成死锁现象。 - 等待是否中断。
synchronized
不可中断,除非正常执行完或者抛出异常;lock可以设置超时中断。 - 加锁是否可以公平。
synchronized
默认是非公平锁,而lock可以自己设置公平与否,默认是非公平锁。 - 锁是否可以绑定多个条件
condition
。synchronized
无法绑定多个条件,而lock可以分组唤醒线程,可以做到精确唤醒某一个线程,而不是像synchronized随机唤醒任意一个线程。
可以利用lock实现精确的唤醒,比如先执行A线程,打印5次,再执行B线程打印10次,再执行C线程打印15次
6 Juc中常见的类CountDownLatch、CyclicBarrier、Semaphore的理解?
1. CountDownLatch
多个线程都执行完成时某一个线程才允许工作,否则该线程必须处于阻塞状态,其他线程工作完时,则latch-1,直到latch==0时,等待的线程才开始工作。
比如:只有其他人都走了(都完成),才可以关灯
2. CyclicBarrier
该类与CyclicBarrier
相反,只有当所有线程都达到某个状态时,才允许所有线程开始执行,否则所有的线程都必须处于等待状态。
比如:所有的人到了才可以开饭(才可以一起做某事)。
3. Semaphore
之前有一篇仔细研究底层的原理文章,移步至:Semaphore
7. 谈谈阻塞队列
之前写过一篇基本的阻塞队列文章,阻塞队列 简单而言:本质上是一个队列,但是与普通队列有不同之处,阻塞队列,只有队列非空(有元素)时才可以获取元素,当队列非满的时候才可以添加元素,否则只能阻塞等待。
为什么需要阻塞队列呢?
在多线程领域,所谓阻塞,在某些情况下会挂起线程,一旦条件满足,被挂起的线程又会自动被唤醒。
通常我们需要手动处理何时阻塞线程、何时唤醒线程,但是有了阻塞队列之后,根本不需要关心该问题,一切都有BlockingQueue
实现。
主要的实现类:
主要运用:生产者消费者模式
传统的生产者消费者写法:
利用阻塞队列实现生产者消费者模式
8. Callable接口理解
是什么?
callable同样是一个线程的接口,但是可以自己定义返回值。
下面可以获取线程的返回结果:
为什么?
因为Callable可以获取线程的计算结果,并且可以做到多个线程之间并行进行,比如先执行主线程,等该线程执行完了之后,在获取FutureTask的执行结果。
9. 线程池用过吗?ThreadPoolExecutor谈谈你的理解?
详细的可以参考之前的博文:JUC—线程池核心类ThreadPoolExecutor源码解析,对于线程池有更加准确的讲解。
9.1 为什么用线程池
线程的生成和销毁都是需要消耗系统的资源,因此可以提前准备好一堆可以使用的线程、供任务调度的使用。主要有以下几点好处:
- 降低资源的消耗。通过重复利用已经创建的线程降低线程创建和线程销毁的资源损耗。
- 提高响应速度。当任务达到时,不需要等待线程创建就可以执行
- 提供线程的可管理性。线程池本身提供了很多方法供用户监控、设置线程池。
9.2 线程池如何使用?
9.3 线程池重要参数讲解
9.4 底层工作原理