1. 什么是进程、线程、协程,他们之间的关系是怎样的?
- 进程:
- 本质上是一个独立执行的程序,进程是操作系统进行资源分配和调度的基本概念,操作系统进行资源分配和调度的一个独立单位。
- 线程:
- 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程中可以并发多个线程,每条线程执行不同的任务,切换受系统控制。
- 协程:
- 又称为微线程,是一种用户态的轻量级线程,协程不像线程和进程需要进行系统内核上的上下文切换,协程的上下文切换是由用户自己决定的,有自己的上下文,所以说是轻量级的线程,也称之为用户级别的线程就叫协程,一个线程可以多个协程,线程进程都是同步机制,而协程则是异步 。Java的原生语法中并没有实现协程,目前python、Lua和GO等语言支持
- 关系:
- 一个进程可以有多个线程,它允许计算机同时运行两个或多个程序。线程是进程的最小执行单位,CPU的调度切换的是进程和线程,进程和线程多了之后调度会消耗大量的CPU,CPU上真正运行的是线程,线程可以对应多个协程:
2. 说下并发和并行的区别,并举例说明
- 并发 concurrency:
- 一核CPU,模拟出来多条线程,快速交替执行。
- 并行 parallellism:
- 多核CPU ,多个线程可以同时执行;
- eg: 线程池!
- 并发指在一段时间内宏观上去处理多个任务。并行指同一个时刻,多个任务确实真的同时运行。
举例:
#### 并发:是一心多用,听课和看电影,但是CPU大脑只有一个,所以轮着来
#### 并行:火影忍者中的影分身,有多个你出现,可以分别做不同的事情
3. java实现多线程有哪几种方式,有什么不同,比较常用哪种?
3.1 继承Thread
- 继承Thread,重写里面
run()
方法,创建实例,执行start - 优点:代码编写最简单直接操作
- 缺点:没返回值,继承一个类后,没法继承其他的类,拓展性差
public class ThreadDemo1 extends Thread {
@Override
public void run() {
System.out.println("继承Thread实现多线程,名称:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
ThreadDemo1 threadDemo1 = new ThreadDemo1();
threadDemo1.setName("demo1");
// 执行start
threadDemo1.start();
System.out.println("主线程名称:"+Thread.currentThread().getName());
}
3.2 实现Runnable接口
- 自定义类实现Runnable,实现里面
run()
方法,创建Thread类,使用Runnable接口的实现对象作为参数传递给Thread对象,调用Strat方法。 - 优点:线程类可以实现多个几接口,可以再继承一个类
- 缺点:没返回值,不能直接启动,需要通过构造一个Thread实例传递进去启动
public class ThreadDemo2 implements Runnable {
@Override
public void run() {
System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
ThreadDemo2 threadDemo2 = new ThreadDemo2();
Thread thread = new Thread(threadDemo2);
thread.setName("demo2");
// start线程执行
thread.start();
System.out.println("主线程名称:"+Thread.currentThread().getName());
}
// JDK8之后采用lambda表达式
public static void main(String[] args) {
Thread thread = new Thread(() -> {
System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
});
thread.setName("demo2");
// start线程执行
thread.start();
System.out.println("主线程名称:"+Thread.currentThread().getName());
}
3.3 实现Callable接口
- 创建callable接口的实现类,并实现
call()
方法,结合FutureTask类包装Callable对象,实现多线程。 - 优点:有返回值,拓展性也高
- 缺点:jdk5以后才支持,需要重写
call()
方法,结合多个类比如FutureTask和Thread类
public class MyTask implements Callable<Object> {
@Override
public Object call() throws Exception {
System.out.println("通过Callable实现多线程,名称:"+Thread.currentThread().getName());
return "这是返回值";
}
}
public static void main(String[] args) {
// JDK1.8 lambda表达式
FutureTask<Object> futureTask = new FutureTask<>(() -> {
System.out.println("通过Callable实现多线程,名称:" +
Thread.currentThread().getName());
return "这是返回值";
});
// MyTask myTask = new MyTask();
// FutureTask<Object> futureTask = new FutureTask<>(myTask);
// FutureTask继承了Runnable,可以放在Thread中启动执行
Thread thread = new Thread(futureTask);
thread.setName("demo3");
// start线程执行
thread.start();
System.out.println("主线程名称:"+Thread.currentThread().getName());
try {
// 获取返回值
System.out.println(futureTask.get());
} catch (InterruptedException e) {
// 阻塞等待中被中断,则抛出
e.printStackTrace();
} catch (ExecutionException e) {
// 执行过程发送异常被抛出
e.printStackTrace();
}
}
3.4 通过线程池创建线程
- 自定义Runnable接口,实现run方法,创建线程池,调用执行方法并传入对象
- 优点:安全高性能,复用线程
- 缺点: jdk5后才支持,需要结合Runnable进行使用
public class ThreadDemo4 implements Runnable {
@Override
public void run() {
System.out.println("通过线程池+runnable实现多线程,名称:" +
Thread.currentThread().getName());
}
}
public static void main(String[] args) {
// 创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
for(int i=0;i<10;i++){
// 线程池执行线程任务
executorService.execute(new ThreadDemo4());
}
System.out.println("主线程名称:"+Thread.currentThread().getName());
// 关闭线程池
executorService.shutdown();
}
- 一般常用的Runnable 和 第四种线程池+Runnable,简单方便扩展,和高性能 (池化的思想)
3.5 Runable Callable Thread 三者区别?
- Thread是一个抽象类,只能被继承,而Runable Callable是接口,需要实现接口中的方法
- 继承Thread重写
run()
方法,实现Runable接口需要实现run()
方法,而Callable是需要实现call()
方法 - Thread和Runable 没有返回值,Callable 有返回值
- 实现Runable 接口的类不能直接调用
start()
方法,需要new 一个Thread并发该实现类放入Thread,再通过新建的Thread实例来调用start()
方法。 - 实现Callable 接口的类需要借助FutureTask(将该实现类放入其中),再将FutureTask实例放入Thread,再通过新建的Thread实例来调用
start()
方法。获取返回值只需要借助FutureTask实例调用get()
方法即可!
4. 线程的几个状态(生命周期)?
线程有几个状态(6个)!
public enum State {
/**
* 线程新生状态
*/
NEW,
/**
* 线程运行中
*/
RUNNABLE,
/**
* 线程阻塞状态
*/
BLOCKED,
/**
* 线程等待状态,死等
*/
WAITING,
/**
* 线程超时等待状态,超过一定时间就不再等
*/
TIMED_WAITING,
/**
* 线程终止状态,代表线程执行完毕
*/
TERMINATED;
}
5. 线程状态转换的相关方法:sleep/yield/join wait/notify/notifyAll
Tread下的方法
##### sleep()
属于线程Thread的方法,让线程暂缓执行,等待预计时间之后再恢复
交出CPU使用权,《不会释放锁》,抱着锁睡觉!
进入超时等待状态TIME_WAITGING,睡眠结束变为就绪Runnable
##### yield()
属于线程Thread的方法,暂停当前线程的对象,去执行其他线程
交出CPU使用权,《不会释放锁》,和sleep类似
作用:让相同优先级的线程轮流执行,但是不保证一定轮流
注意:不会让线程进入阻塞状态BLOCKED,直接变为就绪Runnable,只需要重新获得CPU使用权
##### join()
属于线程Thread的方法,在主线程上运行调用该方法,会让主线程休眠,
《不会释放锁》 让调用join方法的线程先执行完毕,再执行其他线程
类似让救护车警车优先通过!!
Object下的方法
##### wait()
属于Object的方法,当前线程调用对象的wait方法,
《会释放锁》,进入线程的等待队列
需要依靠notify或者notifyAll唤醒,或者wait(timeout)时间自动唤醒
##### notify()
属于Object的方法
唤醒在对象监视器上等待的单个线程,《随机唤醒》
##### notifyAll()
属于Object的方法
唤醒在对象监视器上等待的全部线程,《全部唤醒》
线程状态转换流程图
6. Java中可以有哪些方法来保证线程安全?
- 加锁:比如synchronize/ReentrantLock
- 使用volatile声明变量,轻量级同步,不能保证原子性(需要解释)
- 使用线程安全类,例如原子类 AtomicXXX等
- 使用线程安全集合容器,例如:CopyOnWriteArrayList/ConcurrentHashMap等
- ThreadLocal本地私有变量/信号量Semaphore等
7. 是否了解volatile关键字?能否解释下它和synchronized有什么区别?
线程安全行:
线程安全性包括两个方面,①可见性,②原子性!
volatile特性
-
参考文章: volatile关键字
-
volatile保证线程可见性案例:使用Volatile关键字的案例分析
-
源码分析文章参考:java同步系列之volatile解析
通俗来说就是,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。
二者对比
- volatile是轻量级的synchronized,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象!
- volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法
-
volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。
synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。 - volatile:保证可见性,但是不能保证原子性
- synchronized:保证可见性,也保证原子性
使用场景
对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的;
例:volatile int i = 0;
并且大量线程调用i
的自增操作,那么volatile可以保证变量的安全吗?
不可以保证!,volatile不能保证变量操作的原子性!
-
自增操作包括三个步骤,分别是:读取,加一,写入,由于这三个子操作的原子性不能被保证,那么n个线程总共调用n次
i++
的操作后,最后的i
的值并不是大家想的n,而是一个比n小的数! -
解释:
- 比如A线程执行自增操作,刚读取到
i
的初始值0
,然后就被阻塞了! -
B线程现在开始执行,还是读取到
i
的初始值0
,执行自增操作,此时i
的值为1
- 然后A线程阻塞结束,对刚才拿到的
0
执行加1
与写入操作,执行成功后,i
的值被写成1
了! - 我们预期输出
2
,可是输出的是1
,输出比预期小!
- 比如A线程执行自增操作,刚读取到
-
代码实例:
public class VolatileTest { public volatile int i = 0; public void increase() { i++; } public static void main(String args[]) throws InterruptedException { List<Thread> threadList = new ArrayList<>(); VolatileTest test = new VolatileTest(); for (int j = 0; j < 10000; j++) { Thread thread = new Thread(new Runnable() { @Override public void run() { test.increase(); } }); thread.start(); threadList.add(thread); } // 等待所有线程执行完毕 for (Thread thread : threadList) { thread.join(); } System.out.print(test.i);// 输出9995 } }
总结
volatile不需要加锁,因此不会造成线程的阻塞,而且比synchronized更轻量级,而synchronized可能导致线程的阻塞!volatile由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱!
##### JAVA内存模型简称 JMM
JMM规定所有的变量存在在主内存,每个线程有自己的工作内存,线程对变量的操作都在工作内存中进行,不能直接对主内存就行操作。
使用volatile修饰变量,每次读取前必须从主内存属性最新的值,每次写入需要立刻写到主内存中,volatile关键字修修饰的变量随时看到的自己的最新值,假如线程1对变量v进行修改,那么线程2是可以马上看见!
8. volatile可以避免指令重排,能否解释下什么是指令重排?
- 指令重排序分两类:
- 编译器重排序
- 运行时重排序
JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是为了优化运行效率(不改变程序结果的前提)
int a = 3; // step:1
int b = 4; // step:2
int c =5; // step:3
int h = a*b*c; // step:4
定义顺序: 1,2,3,4
计算顺序: 1,3,2,4 和 2,1,3,4 结果都是一样的
- 虽然指令重排序可以提高执行效率,但是多线程上可能会影响结果,有什么解决办法?
- 解决办法:内存屏障(了解即可~)
- 内存屏障是屏障指令,使CPU对屏障指令之前和之后的内存操作执行结果的一种约束!
扩展:现行发生原则happens-before(了解即可~)
volatile的内存可见性就体现了先行发生原则!
9. 介绍一下并发编程三要素?
- 原子性
- 有序性
- 可见性
9.1 原子性
- 原子性:
- 一个不可再被分割的最小颗粒,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换,线程切换会带来原子性的问题!
int num = 1; // 原子操作
num++; // 非原子操作,从主内存读取num到线程工作内存,进行+1,再把num写回到主内存,
// 除非用原子类:即,java.util.concurrent.atomic里的原子变量类
// 解决办法是可以用synchronized 或 Lock(比如ReentrantLock) 来把这个多步操作“变成”原子操作
// 这里不能使用volatile,前面有说到:对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的
public class XdTest {
// 方式1:使用原子类
// AtomicInteger num = 0;// 这种方式的话++操作就可以保证原子性了,而不需要再加锁了
private int num = 0;
// 方式2:使用lock,每个对象都是有锁,只有获得这个锁才可以进行对应的操作
Lock lock = new ReentrantLock();
public void add1(){
lock.lock();
try {
num++;
}finally {
lock.unlock();
}
}
// 方式3:使用synchronized,和上述是一个操作,这个是保证方法被锁住而已,上述的是代码块被锁住
public synchronized void add2(){
num++;
}
}
解决核心思想:把一个方法或者代码块看做一个整体,保证是一个不可分割的整体!
9.2 有序性
- 有序性:
- 程序执行的顺序按照代码的先后顺序执行,因为处理器可能会对指令进行重排序JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(不改变程序结果的前提)
int a = 3; // step:1
int b = 4; // step:2
int c =5; // step:3
int h = a*b*c; // step:4
定义顺序: 1,2,3,4
计算顺序: 1,3,2,4 和 2,1,3,4 结果都是一样的(单线程情况下)
指令重排序可以提高执行效率,但是多线程上可能会影响结果!
假如下面的场景:
// 线程1
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
run(); // 执行核心业务代码
}
// -----------------指令重排序后,导致顺序换了,程序出现问题,且难排查-----------------
// 线程1
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
run(); // 执行核心业务代码
}
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法
9.3 可见性
- 可见性:
- 一个线程A对共享变量的修改,另一个线程B能够立刻看到!
// 线程 A 执行
int num = 0;
// 线程 A 执行
num++;
// 线程 B 执行
System.out.print("num的值:" + num);
线程A执行 i++ 后再执行线程B,线程B可能有2个结果,可能是0和1。
因为i++
在线程A中执行运算,并没有立刻更新到主内存当中,而线程B就去主内存当中读取并打印,此时打印的就是0
;也可能线程A执行完成更新到主内存了,线程B的值是1
。
所以需要保证线程的可见性:
synchronized、lock和volatile 能够保证线程可见性
volatile保证线程可见性案例:使用Volatile关键字的案例分析
10. Java里面有哪些锁?分别解释下
乐观锁/悲观锁
- 悲观锁:
- 当线程去操作数据的时候,总认为别的线程会去修改数据,所以它每次拿数据的时候总会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized
- 乐观锁:
- 每次去拿数据的时候都认为别人不会修改,更新的时候会判断是别人是否回去更新数据,通过版本来判断,如果数据被修改了就拒绝更新,比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如说数据库的乐观锁,通过版本控制来实现,CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响
- 小结:悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁大!
公平锁/非公平锁
- 公平锁:
- 指多个线程按照申请锁的顺序来获取锁,简单来说 如果一个线程组里,能保证每个线程都能拿到锁 比如ReentrantLock(底层是同步队列FIFO: First Input First Output来实现)
- 非公平锁:
- 获取锁的方式是随机获取的,保证不了每个线程都能拿到锁,也就是存在有线程饿死,一直拿不到锁,比如synchronized、ReentrantLock
- 小结:非公平锁性能高于公平锁,更能重复利用CPU的时间。ReentrantLock中可以通过构造方法指定是否为公平锁,默认为非公平锁!synchronized无法指定为公平锁,一直都是非公平锁。
可重入锁/不可重入锁
- 可重入锁:
- 也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。
- 不可重入锁:
- 若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞
- 小结:可重入锁能一定程度的避免死锁 synchronized、ReentrantLock都是可重入锁!
独占锁/共享锁
-
独享锁,是指锁一次只能被一个线程持有。
- 也叫X锁/排它锁/写锁/独享锁:该锁每一次只能被一个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。例子:如果 线程A 对 data1 加上排他锁后,则其他线程不能再对 data1 加任何类型的锁,获得独享锁的线程即能读数据又能修改数据!
-
共享锁,是指锁一次可以被多个线程持有。
- 也叫S锁/读锁,能查看数据,但无法修改和删除数据的一种锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享!
ReentrantLock和synchronized都是独享锁,ReadWriteLock的读锁是共享锁,写锁是独享锁。
互斥锁/读写锁
与独享锁/共享锁的概念差不多,是独享锁/共享锁的具体实现。
ReentrantLock和synchronized都是互斥锁,ReadWriteLock是读写锁
自旋锁
- 自旋锁:
- 一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
- 不会发生线程状态的切换,一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU。
- 常见的自旋锁:TicketLock,CLHLock,MSCLock
死锁
- 死锁:
- 两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去!
下面三种是Jvm为了提高锁的获取与释放效率而做的优化 针对Synchronized的锁升级,锁的状态是通过对象监视器在对象头中的字段来表明,是不可逆的过程
- 偏向锁:
- 一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,获取锁的代价更低!
- 轻量级锁:
- 当锁是偏向锁的时候,被其他线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,但不会阻塞,且性能会高点!
- 重量级锁:
- 当锁为轻量级锁的时候,其他线程虽然是自旋,但自旋不会一直循环下去,当自旋一定次数的时候且还没有获取到锁,就会进入阻塞,该锁升级为重量级锁,重量级锁会让其他申请的线程进入阻塞,性能也会降低!
11. 写个多线程死锁的例子
线程在获得了锁A并且没有释放的情况下去申请锁B,这时另一个线程已经获得了锁B,在释放锁B之前又要先获得锁A,因此闭环发生,陷入死锁循环:
public class DeadLockDemo {
private static String locka = "locka";
private static String lockb = "lockb";
public void methodA(){
synchronized (locka){
System.out.println("我是A方法中获得了锁A "+Thread.currentThread().getName() );
// 让出CPU执行权,不释放锁
try {
Thread.sleep(2000);// sleep不释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(lockb){
System.out.println("我是A方法中获得了锁B "+Thread.currentThread().getName() );
}
}
}
public void methodB(){
synchronized (lockb){
System.out.println("我是B方法中获得了锁B "+Thread.currentThread().getName() );
// 让出CPU执行权,不释放锁
try {
Thread.sleep(2000);// sleep不释放锁
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(locka){
System.out.println("我是B方法中获得了锁A "+Thread.currentThread().getName() );
}
}
}
public static void main(String [] args){
System.out.println("主线程运行开始运行:"+Thread.currentThread().getName());
DeadLockDemo deadLockDemo = new DeadLockDemo();
new Thread(()->{
deadLockDemo.methodA();
}).start();
new Thread(()->{
deadLockDemo.methodB();
}).start();
System.out.println("主线程运行结束:"+Thread.currentThread().getName());
}
}
死锁的4个必要条件:
- 互斥条件:资源不能共享,只能由一个线程使用!
- 请求与保持条件:线程已经获得一些资源,但因请求其他资源发生阻塞,对已经获得的资源保持不释放!
- 不可抢占:有些资源是不可强占的,当某个线程获得这个资源后,系统不能强行回收,只能由线程使用完自己释放!
- 循环等待条件:多个线程形成环形链,每个都占用对方申请的下个资源!
只要发生死锁,上面的条件都成立,只要一个不满足,就不会发生死锁!
12. 设计一个简单的不可重入锁
不可重入锁:若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞!
public class UnreentrantLock {
private boolean isLocked = false;
// 加锁方法
public synchronized void lock() throws InterruptedException {
System.out.println("进入lock加锁 "+Thread.currentThread().getName());
// 判断是否已经被锁,如果被锁则当前请求的线程进行等待
while (isLocked){
System.out.println("进入wait等待 "+Thread.currentThread().getName());
wait();
}
// 如果还没被加锁,则进行加锁
isLocked = true;
}
// 解锁方法
public synchronized void unlock(){
System.out.println("进入unlock解锁 "+Thread.currentThread().getName());
isLocked = false;
// 唤醒对象锁池里面的一个线程
notify();
}
}
public class Main {
private UnreentrantLock unreentrantLock = new UnreentrantLock();
// 加锁建议在try里面,解锁建议在finally
public void methodA(){
try {
unreentrantLock.lock();
System.out.println("methodA方法被调用");
// methodA()中嵌套调用methodB(),测试methodB()是否能获取锁的执行权
methodB();
}catch (InterruptedException e){
e.fillInStackTrace();
} finally {
unreentrantLock.unlock();
}
}
public void methodB(){
try {
unreentrantLock.lock();
System.out.println("methodB方法被调用");
}catch (InterruptedException e){
e.fillInStackTrace();
} finally {
unreentrantLock.unlock();
}
}
public static void main(String [] args){
// 演示同一个线程下是否可冲入!(如果单线程都是不可重入的话,多线程下就不用说了~)
new Main().methodA();
}
}
// 同一个线程,重复获取锁失败,形成死锁,这个就是不可重入锁
13. 设计一个简单的可重入锁
可重入锁:也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁
public class ReentrantLock {
private boolean isLocked = false;
// 用于记录是不是重入的线程
private Thread lockedOwner = null;
// 累计加锁次数,加锁一次累加1,解锁一次减少1
private int lockedCount = 0;
// 加锁方法
public synchronized void lock() throws InterruptedException {
System.out.println("进入lock加锁 "+Thread.currentThread().getName());
// 获取当前线程
Thread thread = Thread.currentThread();
// 判断是否是同个线程获取锁, lockedOwner != thread引用地址的比较
// 如果已经加锁,且当前线程不是之前加锁的线程则阻塞等待!
while (isLocked && lockedOwner != thread ){
System.out.println("进入wait等待 "+Thread.currentThread().getName());
System.out.println("当前锁状态 isLocked = "+isLocked);
System.out.println("当前count数量 lockedCount = "+lockedCount);
wait();
}
// 如果没有加锁,或者当前线程是之前加锁的线程,则:
// 进行加锁,两次线程地址相同,加锁次数++
isLocked = true;
lockedOwner = thread;
lockedCount++;
}
// 解锁方法
public synchronized void unlock(){
System.out.println("进入unlock解锁 "+Thread.currentThread().getName());
// 获取当前线程
Thread thread = Thread.currentThread();
// 线程A加的锁,只能由线程A解锁,其他线程B不能解锁
if(thread == this.lockedOwner){
lockedCount--;
if(lockedCount == 0){
// 解锁
isLocked = false;
lockedOwner = null;
// 唤醒对象锁池里面的一个线程
notify();
}
}
}
}
public class Main {
//private UnreentrantLock unreentrantLock = new UnreentrantLock();
private ReentrantLock reentrantLock = new ReentrantLock();
// 加锁建议在try里面,解锁建议在finally
public void methodA(){
try {
reentrantLock.lock();
System.out.println("methodA方法被调用");
methodB();
}catch (InterruptedException e){
e.fillInStackTrace();
} finally {
reentrantLock.unlock();
}
}
public void methodB(){
try {
reentrantLock.lock();
System.out.println("methodB方法被调用");
}catch (InterruptedException e){
e.fillInStackTrace();
} finally {
reentrantLock.unlock();
}
}
public static void main(String [] args){
for(int i=0 ;i<10;i++){
// 演示的是同个线程
new Main().methodA();
}
}
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ttX91EiY-1613882316860)(小滴课堂并发与多线程相关面试题总结.assets/image-20210220161315283.png)]
14. 介绍下你对synchronized的理解?
源码分析文章参考:java同步系列之synchronized解析
- synchronized是解决线程安全的问题,常用在同步普通方法、静态方法、代码块中使用!
- synchronized非公平、可重入锁!
- 每个对象有一个锁和一个等待队列,锁只能被一个线程持有,其他需要锁的线程需要阻塞等待。锁被释放后,对象会从队列中取出一个并唤醒,唤醒哪个线程是不确定的,不保证公平性
15. 解释下什么是CAS?以及ABA问题?
CAS全称:Compare and Swap 比较并交换
Unsafe实现原理,参考文章:java魔法类之Unsafe解析
- CAS底层通过Unsafe类实现原子性操作,操作包含三个操作数:
- 对象内存地址(V):
- 预期原值(A):
- 新值(B)
- 理解方式1:比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么则执行交换操作!如果不是就一直循环!
- 理解方式2:如果内存地址中的值与预期原值相匹配,那么处理器会自动将该地址的值更新为新值 ,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
CAS属于乐观锁,性能较悲观锁有很大的提高!
AtomicXXX 等原子类底层就是CAS实现,一定程度比synchonized好,因为后者是悲观锁!
小滴老师讲这块的时候,对于第一次接触CAS的萌新有些不好理解,这里我参考狂神老师在介绍CAS的时候的一些理解:以一个案例入手:
案例:
public class CASDemo {
// CAS compareAndSet : 比较并交换!
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(2020);
// 期望、更新
// public final boolean compareAndSet(int expect, int update)
// 如果我期望的值达到了,那么就更新,否则,
// 就不更新, CAS 是CPU的并发原语!
System.out.println(atomicInteger.compareAndSet(2020, 2021));// true
System.out.println(atomicInteger.get());// 2021
//atomicInteger.getAndIncrement()// 看底层如何实现 ++
System.out.println(atomicInteger.compareAndSet(2020, 2021));// false
System.out.println(atomicInteger.get());// 2021
}
}
我们来看一下getAndIncrement()
方法的底层实现:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// UnSafe类,底层是调用C++:Java无法操作内存,所以这里借助C++来操作内存
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
// 获取内存偏移值valueOffset
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// value被volatile修饰,避免指令重排,且保证线程可见性和有序性
private volatile int value;
...
public final int getAndIncrement() {
// 参数:
// this: 当前对象
// valueOffset:当前对象的内存偏移地址
// 1:值
return unsafe.getAndAddInt(this, valueOffset, 1);
}
...
}
大致了解UnSafe后,我们继续点进getAndIncrement()方法中,unsafe调用的getAndAddInt()方法查看:
// 位于UnSafe类中
// 参数:var1 当前对象,var2 当前对象的内存偏移地址,var4 值(1)
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
// 这里用到了自旋锁:一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
do {
// 获取内存地址中的原对象的值
var5 = this.getIntVolatile(var1, var2);
// 借助CAS比较并交换,来实现getAndIncrement()方法的自增+1功能!
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
...
// 调用C++,执行比较并交换
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
CAS : 比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么则执行操作!如果不是就
一直循环!
CAS的ABA问题?
狸猫换太子
public class CasAbaTest {
// CAS compareAndSet : 比较并交换!
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(2020);
/*
* 类似于我们平时写的SQL:乐观锁
*
* 如果某个线程在执行操作某个对象的时候,其他线程若操作了该对象,
* 即使对象内容未发生变化,也需要告诉我。
*
* 期望、更新:
* public final boolean compareAndSet(int expect, int update)
* 如果我期望的值达到了,那么就更新,否则,就不更新,
* CAS 是CPU的并发原语!
*/
// ============== 捣乱的线程 ==================
System.out.println(atomicInteger.compareAndSet(2020, 2021));
System.out.println(atomicInteger.get());
System.out.println(atomicInteger.compareAndSet(2021, 2020));
System.out.println(atomicInteger.get());
// ============== 期望的线程 ==================
System.out.println(atomicInteger.compareAndSet(2020, 6666));
System.out.println(atomicInteger.get());
}
}
输出结果:
true
2021
true
2020
true
6666
上述案例中:假设我们期望的线程本来是需要将2020更换成6666,然而有一个捣乱的线程抢在期望线程之前执行,先把2020更换为了2021,然后又将2021更换回2020!
这样看上去当期望线程执行时,初始值仍为2020没有改变,但是实际上在捣乱线程中已经执行过2次更换操作了,而我们的期望线程并不知情!这就是ABA问题!
如何解决ABA问题?
本质上相当于采用乐观锁策略解决ABA问题!
public class CASDemo {
/**
* AtomicStampedReference 注意,
* 如果泛型是一个包装类,就需要注意对象的引用问题
* 正常在业务操作,这里面比较的都是一个个对象
*/
// 参数1:初始值100
// 参数2:初始对应的版本号 initialStamp=1
static AtomicStampedReference<Integer> atomicStampedReference =
new AtomicStampedReference<>(100,1);
// CAS compareAndSet : 比较并交换!
public static void main(String[] args) {
// 线程A:
new Thread(()->{
// 线程执行时,先获得initialStamp版本号
int stamp = atomicStampedReference.getStamp();
System.out.println("A线程第1次拿到的版本号为:"+stamp);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// cas比较并交换:100--->101
atomicStampedReference.compareAndSet(
100,
101,
atomicStampedReference.getStamp(),// 获得最新版本号
// 更新版本号
atomicStampedReference.getStamp() + 1);
System.out.println("A线程第2次拿到的版本号为:"
+atomicStampedReference.getStamp());
// cas比较并交换:101--->100
System.out.println("A线程第2次是否执行了CAS:" +
atomicStampedReference.compareAndSet(
101,
100,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1));
System.out.println("A线程第3次拿到的版本号为:"
+atomicStampedReference.getStamp());
},"A").start();
// 乐观锁的原理相同!
// 线程B:
new Thread(()->{
// 获得版本号
int stamp = atomicStampedReference.getStamp();
System.out.println("B线程第1次拿到的版本号为:"+stamp);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
// cas比较并交换:100--->99
System.out.println("B线程第1次是否执行了CAS:" +
atomicStampedReference.compareAndSet(
100,
99,
stamp,
stamp + 1));
System.out.println("B线程第2次拿到的版本号为:"
+atomicStampedReference.getStamp());
},"B").start();
}
}
这样,在版本号initialStamp的限制下,每执行一次CAS,都会将版本号+1,这样即使出现了 “狸猫换太子” 情况,期望线程也能及时知道!
输出结果如下:
A线程第1次拿到的版本号为:1
B线程第1次拿到的版本号为:1
A线程第2次拿到的版本号为:2
A线程第2次是否执行了CAS:true
A线程第3次拿到的版本号为:3
B线程第1次是否执行了CAS:false
B线程第2次拿到的版本号为:3
总的来说,与MySQL的乐观锁表中加一个version字段原理相同!
注意:
Integer 使用了对象缓存机制,默认范围是 -128 ~ 127 ,推荐使用静态工厂方法 valueOf 获取对象实例,而不是 new,因为 valueOf 使用缓存,而 new 一定会创建新的对象分配新的内存空间;
下面是阿里巴巴开发手册的规范点:
所以上面的案例,如果使用大于-128-127范围的数字时候就会出现2个flase的情况!这里小伙伴一定要注意下~
16. 介绍下你对AQS的理解?
参考文章:AQS面试详解
AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。这个类在java.util.concurrent.locks包下面。
它是一个Java提高的底层同步工具类,比如CountDownLatch、ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的!
实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物!
17. ReentrantLock和synchronized的差别?
- ReentrantLock和synchronized都是独占锁,可重入锁,悲观锁
- synchronized:
- 1、java内置关键字
- 2、无法判断是否获取锁的状态,只能是非公平锁!
- 3、加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
- 4、一般并发场景使用足够、可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
- ReentrantLock:
- 1、是个Lock接口的实现类
- 2、可以判断是否获取到锁,可以为公平锁也可以是非公平锁(默认)
- 3、需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁
- 5、创建的时候通过传进参数true创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
- 6、底层是AQS的state和FIFO队列来控制加锁
18. ReentrantReadWriteLock和ReentrantLock有什么区别?
ReentrantReadWriteLock
1、读写锁接口ReadWriteLock接口的一个具体实现,实现了读写锁分离
2、支持公平和非公平,底层也是基于AQS实现
3、允许从写锁降级为读锁:
流程:先获取写锁,然后获取读锁,最后释放写锁;但不能从读锁升级到写锁
4、重入:
-
读锁后还可以获取读锁;
-
获取了写锁之后既可以再次获取写锁又可以获取读锁
-
读锁是共享的,写锁是独占的!读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,主要是提升了读写的性能 !
ReentrantLock是独占锁且可重入的,相比synchronized而言功能更加丰富也更适合复杂的并发场景,但是也有弊端,假如有两个线程A/B访问数据,加锁是为了防止线程A在写数据, 线程B在读数据造成的数据不一致; 但线程A在读数据,线程C也在读数据,读数据是不会改变数据没有必要加锁,但是ReentrantLock还是加锁了,降低了程序的性能,所以就有了ReadWriteLock读写锁接口!
19. 是否了解阻塞队列BlockingQueue?
BlockingQueue阻塞队列
- ArrayBlockingQueue,ArrayBlockingQueue
- put方法用来向队尾存入元素,如果队列满,则阻塞
- take方法用来从队首取元素,如果队列为空,则阻塞
BlockingQueue: juc包下的提供了线程安全的队列访问的接口,并发包下很多高级同步类的实现都是基于阻塞队列实现的!
- 1、当阻塞队列进行插入数据时,如果队列已满,线程将会阻塞等待直到队列非满
- 2、从阻塞队列读数据时,如果队列为空,线程将会阻塞等待直到队列里面是非空的时候
常见的阻塞队列
- ArrayBlockingQueue:
- 基于数组实现的一个阻塞队列,需要指定容量大小,FIFO先进先出顺序;
- LinkedBlockingQueue:
- 基于链表实现的一个阻塞队列,如果不指定容量大小,默认
Integer.MAX_VALUE
,FIFO先进先出顺序;
- 基于链表实现的一个阻塞队列,如果不指定容量大小,默认
- PriorityBlockingQueue:
- 一个支持优先级的无界阻塞队列,默认情况下元素采用自然顺序升序排序,也可以自定义排序实现java.lang.Comparable接口;
- DelayQueue:
- 延迟队列,在指定时间才能获取队列元素的功能,队列头元素是最接近过期的元素,里面的对象必须实现 java.util.concurrent.Delayed 接口并实现CompareTo和getDelay方法;
扩展:你知道非阻塞队列ConcurrentLinkedQueue吗,它怎么实现线程安全的?
参考文章:Java并发编程之ConcurrentLinkedQueue详解
20. java里有哪些是常用的线程池?
使用线程池的好处:
重用存在的线程,减少对象创建销毁的开销,有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能
类别:
- newFixedThreadPool :
- 一个定长线程池,可控制线程最大并发数
- newCachedThreadPool:
- 一个可缓存线程池
- newSingleThreadExecutor:
- 一个单线程化的线程池,用唯一的工作线程来执行任务
- newScheduledThreadPool:
- 一个定长线程池,支持定时/周期性任务执行
【阿里巴巴编码规范】 线程池不允许使用 Executors 去创建,要通过 ThreadPoolExecutor的方式原因?
Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等
如果使用不当,会造成资源耗尽问题
直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险
##### 常见的线程池问题:
newFixedThreadPool和newSingleThreadExecutor:
队列使用LinkedBlockingQueue,队列长度为 Integer.MAX_VALUE,可能造成堆积,导致OOM
newScheduledThreadPool和newCachedThreadPool:
线程池里面允许最大的线程数是Integer.MAX_VALUE,可能会创建过多线程,导致OOM
ThreadPoolExecutor构造函数里面的参数,能否解释下各个参数的作用?
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
-
corePoolSize
:核心线程数,线程池也会维护线程的最少数量,默认情况下核心线程会一直存活,即使没有任务也不会受存keepAliveTime控制!
坑:在刚创建线程池时线程不会立即启动,到有任务提交时才开始创建线程并逐步线程数目达到corePoolSize -
maximumPoolSize
:线程池维护线程的最大数量,超过将被阻塞!
坑:当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程 -
keepAliveTime
:非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于corePoolSize -
unit
:指定keepAliveTime的单位,如TimeUnit.SECONDS、TimeUnit.MILLISECONDS -
workQueue
:线程池中的任务队列,常用的是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue -
threadFactory
:创建新线程时使用的工厂 -
handler
:RejectedExecutionHandler是一个接口且只有一个方法,线程池中的数量大于maximumPoolSize,对拒绝任务的处理策略,默认有4种策略:- AbortPolicy
- CallerRunsPolicy
- DiscardOldestPolicy
- DiscardPolicy