参考资料:【纯干货】Java 并发进阶常见面试题总结、J.U.C|一文搞懂AQS
目录
2、synchronized 关键字和 volatile 关键字的区别
4、synchronized和ReentrantLock 的区别
volatile
1、Java内存模型
Java 内存模型下,工作内存保存的是主内存中的副本,线程操作的是工作内存中的变量,然后刷新到主内存中,而不是直接在主存中进行读写。
这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是不稳定的,每次使用它都到主存中进行读取。volatile 关键字的主要作用就是保证变量的可见性然后还有一个作用是防止指令重排序。
为什么volatile能保证可见性,不能保证原子性?
例如你让一个volatile的integer自增(i++),其实要分成3步:
1)读取volatile变量值到local;
2)增加变量的值;
3)把local的值写回,让其它的线程可见。这3步的jvm指令为:
mov 0xc(%r10),%r8d ; Load inc %r8d ; Increment mov %r8d,0xc(%r10) ; Store lock addl $0x0,(%rsp) ; StoreLoad Barrier
StoreLoad Barrier就是内存屏障。内存屏障(memory barrier) 是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) 影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障, 相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会 把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。
内存屏障和volatile什么关系?上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障 指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
明白了内存屏障这个CPU指令,回到前面的JVM指令:从Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的,中间如果其他的CPU修改了值将会丢失。
所以volatile不能保证i++操作的原子性。
2、synchronized 关键字和 volatile 关键字的区别
-
volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。实际开发中使用 synchronized 关键字的场景还是更多一些。
-
多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
-
volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
-
volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
sychronized
sychronized主要是通过获取对象锁的方式解决多个线程之间访问资源的同步性。
1、synchronized关键字最主要的三种使用方式:
-
给实例方法加锁:
比如 Synchronized(变量名)、Synchronized(this) 、实例方法等,说明加解锁对象为该对象。
//修饰实例方法
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}
- 给静态方法加锁:
也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。
所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
-
修饰代码块:
指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
若 Synchronized 修饰的代码块中华是类则对类加锁,如果是对象则对对象加锁。
//修饰代码块(实例对象)
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}
//双重校验锁实现对象单例(线程安全)
//修饰代码块(类对象)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
/*
需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
1、为 uniqueInstance 分配内存空间
2、初始化 uniqueInstance
3、将 uniqueInstance 指向分配的内存地址
但由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
*/
注意,当一个对象被锁住时,对象里面所有用 Synchronized 修饰的方法都将产生堵塞,而对象里非 synchronized 修饰的方法可正常被调用,不受锁影响。
2、synchronized 原理
① synchronized 同步语句块的情况
通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java
命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class
。
从上面我们可以看出:
synchronized 同步代码块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
② synchronized 修饰方法的的情况
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
3、synchronized四种锁状态的升级
无锁-->偏向锁-->轻量级锁-->重量级锁
4、synchronized和ReentrantLock 的区别
- 两者都是可重入锁
两者都是可重入锁。就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁 - Lock是java的一个interface接口,而synchronized是Java中的关键字
synchronized是由JDK实现的,不需要程序员编写代码去控制加锁和释放;Lock的接口如下:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
- synchronized修饰的代码在执行异常时,jdk会自动释放线程占有的锁,不需要程序员去控制释放锁,因此不会导致死锁现象发生;但是,当Lock发生异常时,如果程序没有通过unLock()去释放锁,则很可能造成死锁现象,因此Lock一般都是在finally块中释放锁;格式如下:
Lock lock = new LockImpl; // new 一个Lock的实现类
lock.lock(); // 加锁
try{
//todo
}catch(Exception ex){
// todo
}finally{
lock.unlock(); //释放锁
}
- Lock可以让等待锁的线程响应中断处理,如tryLock(long time, TimeUnit unit),而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够中断,程序员无法控制;
- ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的;
为什么说 Synchronized 是非公平锁?
非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。
lock
原理:
Synchronized 通过在对象头中设置标记实现了这一目的,是一种 JVM 原生的锁实现方式,而 ReentrantLock 以及所有的基于 Lock 接口的实现类,都是通过用一个 volitile 修饰的 int 型变量,并保证每个线程都能拥有对该 int 的可见性和原子修改,其本质是基于所谓的 AQS 框架。
lock接口的实现:
ReentrantLock(可重入锁)、ReadWriteLock(读写锁)。读写锁维护了一对读锁和写锁,写入独占,读取共享资源
public class Main {
public static void main(String[] args) {
ReadWriteLockDemo rw = new ReadWriteLockDemo();
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
rw.get();
}
}).start();
}
new Thread(new Runnable() {
@Override
public void run() {
rw.set((int)(Math.random() * 101));
}
}, "Write:").start();
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
rw.get();
}
}).start();
}
new Thread(new Runnable() {
@Override
public void run() {
rw.set((int)(Math.random() * 101));
}
}, "Write:").start();
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
rw.get();
}
}).start();
}
}
}
class ReadWriteLockDemo{
private int number = 0;
private ReadWriteLock lock = new ReentrantReadWriteLock();
//读
public void get(){
lock.readLock().lock(); //上锁
try{
System.out.println(Thread.currentThread().getName() + " : " + number);
}finally{
lock.readLock().unlock(); //释放锁
}
}
//写
public void set(int number){
lock.writeLock().lock();
try{
System.out.println(Thread.currentThread().getName());
this.number = number;
}finally{
lock.writeLock().unlock();
}
}
}
ThreadLocal
ThreadLocal的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,如何防止自己的变量被其它线程篡改。
如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal变量名的由来。他们可以使用 get() 和 set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
使用场景:
Spring 中的单例 bean 的线程安全问题:⼤部分时候我们并没有在系统中使用多线程,所以很少有⼈会关注这个问题。单例 bean 存在线程问题,主要是因为当多个线程操作同⼀个对象的时候,对这个对象的非静态成员变量的写操作会存在线程安全问题。
常见的解决办法:在类中定义⼀个ThreadLocal成员变量,将需要的可变成员变量保存在 ThreadLocal 中。
线程池
1、为什么要用线程池?
降低资源消耗。提高响应速度。提高线程的可管理性。
2、实现Runnable接口和Callable的区别
实现Callable接口可以返回结果或抛出异常,用Future.get()接收。
3、实现Runnable接口和继承Thread区别
其实没有本质上的区别,只不过是写法不同,但由于Runnable是接口,实现Runnable的同时还可以继承其他类和实现其他接口。
public class Test3 extends Thread {
private int ticket = 10;
public void run(){
for(int i =0;i<10;i++){
synchronized (this){
if(this.ticket>0){
try {
Thread.sleep(100);
System.out.println(Thread.currentThread().getName()+"卖票---->"+(this.ticket--));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public static void main(String[] arg){
Test3 t1 = new Test3();
new Thread(t1,"线程1").start();
new Thread(t1,"线程2").start();
//注意,这里也可以直接new Test3().start()来启动线程,只不过上面的方式相当于启动两个线程来跑一个任务(t1 ),如果直接写两条new Test3().start(),那么这两条线程单独运行,里面的ticket也不会共享,因为我们没有锁住类
}
}
result:
Runnable ticket = 5
Runnable ticket = 4
Runnable ticket = 3
Runnable ticket = 1
Runnable ticket = 0
Runnable ticket = 2
Process finished with exit code 0
public class Test2 {
public static void main(String[] args) {
// TODO Auto-generated method stub
//一个Runnable可以理解为一个task任务,必须在Thread里才是在线程中运行起来,如果直接MyThread2.run(),其实是在主线程中运行,没有太大意义。
MyThread2 mt=new MyThread2();
new Thread(mt).start();
new Thread(mt).start();
}
}
class MyThread2 implements Runnable{
private int ticket = 5;
public void run(){
while(true){
System.out.println("Runnable ticket = " + ticket--);
if(ticket < 0){
break;
}
}
}
}
4、execute与submit
executorService.execute(new Runnable()....)和executorService.submit(new Runnable()..../new Callable())都是提交一个任务,只不过submit(callable)可以用Future future接收一个任务结果。submit(new Runnable())接手的future只能用来检测Runnable是否完成,完成的话future.get()为null。
5、线程池
三个重要参数:
corePoolSize:核心线程数
核心线程数线程数定义了最小可以同时运行的线程数量,会一直存活。
workQueue:队列当线程池正在运行的线程数量已经达到corePoolSize,那么再通过execute添加新的任务则会被加workQueue队列中,在队列中排队等待执行,而不会立即执行。
一般用ArrayBlockingQueue,有界缓存等待队列。指定大小。
①SynchronousQueue,无缓冲等待队列,没有容量。是一个不存储元素的阻塞队列,会直接将任务交给消费者,必须等队列中的添加元素被消费后才能继续添加新的元素。使用SynchronousQueue阻塞队列一般要求maximumPoolSizes为无界,避免线程拒绝执行操作。②LinkedBlockingQueue,无界(没有大小限制)缓存等待队列。当前执行的线程数量达到corePoolSize的数量时,剩余的元素会在阻塞队列里等待,在使用此阻塞队列时maximumPoolSizes就相当于无效了。基于链表,FIFO。③ ArrayBlockingQueue,有界缓存等待队列,可以指定缓存队列的大小 ,当线程数量大于corePoolSize时,多余的任务会缓存在ArrayBlockingQueue队列中等待有空闲的线程时继续执行;当ArrayBlockingQueue满时,则又会开启新的线程去执行,直到线程数量达到maximumPoolSize;当线程数已经达到最大的maximumPoolSize时,再有新的任务到达时会执行拒绝执行策略(RejectedExecutionException)。基于数组,也是FIFO。
maximumPoolSize:最大线程数
当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
ThreadPoolExecutor
饱和策略:
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了时,ThreadPoolTaskExecutor
定义一些策略:
-
ThreadPoolExecutor.AbortPolicy
:抛出RejectedExecutionException
来拒绝新任务的处理。 -
ThreadPoolExecutor.CallerRunsPolicy
:调用执行自己的线程运行任务。您不会任务请求。但是这种策略会降低对于新任务提交速度,影响程序的整体性能。另外,这个策略喜欢增加队列容量。如果您的应用程序可以承受此延迟并且你不能任务丢弃任何一个任务请求的话,你可以选择这个策略。 -
ThreadPoolExecutor.DiscardPolicy
: 不处理新任务,直接丢弃掉。 -
ThreadPoolExecutor.DiscardOldestPolicy
: 此策略将丢弃最早的未处理的任务请求。
举个例子:
Spring 通过 ThreadPoolTaskExecutor
或者我们直接通过 ThreadPoolExecutor
的构造函数创建线程池的时候,当我们不指定 RejectedExecutionHandler
饱和策略的话来配置线程池的时候默认使用的是 ThreadPoolExecutor.AbortPolicy,
你将丢失对这个任务的处理。
对于可伸缩的应用程序,建议使用 ThreadPoolExecutor.CallerRunsPolicy
。当最大池被填满时,此策略为我们提供可伸缩队列。
6、CAS
原理:
先读取主内存值,然后更新值之前再次读取主内存的值,如果和预期值相等,就更新。若已被修改,则重新执行读取流程,CAS是通过无限循环来获取数据的。
存在问题:
存在ABA问题,可以用版本号+变量值的方法解决,每次更新值都给版本号+1。
7、AQS
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
AQS核心思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么将暂时获取不到锁的线程加入到队列中。AQS内部就是包含了三个组件:state 资源状态、exclusiveOwnerThread 持有资源的线程、CLH 同步等待队列。
- 核心变量 (volatile)state 变量其代表了加锁的状态,初始值为0。OwnerThread 持有锁的线程,默认值为null。
- 接着线程1过来通过lock.lock()方式获取锁,获取锁的过程就是通过CAS操作volatile 变量state 将其值从0变为1。如果之前没有人获取锁,那么state的值肯定为0,此时线程1加锁成功将state = 1。
- 线程1加锁成功后将OwnerThread 设置成为自己。