【Android面试】2023最新面试专题八:Java并发编程(
11 死锁的场景和解决方案 腾讯
这道题想考察什么?
是否真正了解死锁的定义?是否掌握死锁的排查与解决
考察的知识点
并发编程 死锁
考生应该如何回答
死锁的定义
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
危害
1、线程不工作了,但是整个程序还是活着的
2、没有任何的异常信息可以供我们检查。
3、一旦程序发生了发生了死锁,是没有任何的办法恢复的,只能重启程序,对正式已发布程序来说,这是个很严重的问题。
死锁的发生必须具备以下四个必要条件。
- 互斥条件:指进程对所分配到的资源进行排它性使用,即在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,则请求者只能等待,直至占有资源的进程用毕释放。
- 请求和保持条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 环路等待条件:指在发生死锁时,必然存在一个进程——资源的环形链,即进程集合{P0,P1,P2,···,Pn}中的P0正在等待一个P1占用的资源;P1正在等待P2占用的资源,……,Pn正在等待已被P0占用的资源。
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生。
- 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
- 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
- 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
- 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
避免死锁常见的算法有:有序资源分配法、银行家算法等。
有序资源分配法
有序资源分配法是预防死锁的一种算法,按某种规则对系统中的所有资源统一编号,申请时必须以上升的次序。
例如做饭时候盐为1,酱油为2等等。如果A、B两个厨师同时做饭,使用资源顺序分别为:
A:申请顺序1->2
B:申请顺序2->1
此时,A在拿着盐的同时要使用酱油,但是由于酱油被B持有,两人谁也不让谁。此时形成环路条件,造成死锁 。但是采用有序资源分配法,则:
A:申请顺序1->2
B:申请顺序1->2
A如果先获取到盐,那么B此时只能等待。这样就破坏了环路条件,避免了死锁的发生。
总结
死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有B一个去,不要2个,打十个都没问题;单资源呢?只有13,A和B也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。
12 锁分哪几类?
这道题想考察什么?
是否了解并发相关锁的知识?
考察的知识点
- 锁的分类和概念
- 如何运用锁解决并发问题
考生应该如何回答
Java锁的种类
- 乐观锁/悲观锁
- 独享锁/共享锁
- 互斥锁/读写锁
- 可重入锁
- 公平锁/非公平锁
- 分段锁
- 偏向锁/轻量级锁/重量级锁
- 自旋锁
以上是一些锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计。
乐观锁/悲观锁
乐观锁与悲观锁并不是特指某两种类型的锁,是人们定义出来的概念或思想,主要是指看待并发同步的角度。
- 乐观锁:获取数据时认为不会被其他线程修改,所以不会上锁,但是在更新的时候会判断其他线程是否修改此数据,如果被其他线程修改,则会发生自旋。
- 悲观锁:总是假设最坏的情况,获取数据时都认为其他线程会修改,因此在获取数据时都会上锁,这样保证其他线程需要等待获取锁的线程处理完成并且释放锁。
乐观锁适用于频繁读取的场景,因为不会上锁,因此可以提高吞吐量。在Java中java.util.concurrent.atomic包下面的原子变量类就是基于乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。
悲观锁适合写操作较多的场景,synchronized关键字的实现就是悲观锁。
独享锁/共享锁
- 独享锁是指该锁一次只能被一个线程所持有。
- 共享锁是指该锁可被多个线程所持有。
ReentrantLock是独享锁。但是对于Lock的另一个实现读写锁ReadWriteLock,读锁是共享锁,而写锁则是独享锁。
互斥锁/读写锁
上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
-
互斥锁在Java中的具体实现就是ReentrantLock。
-
读写锁在Java中的具体实现就是ReadWriteLock。
可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。synchronized与ReetrantLock都是可重入锁。可重入锁的一个好处就是可以在一定程度避免死锁:
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB();
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
上述代码中,如果synchronized不是可重入锁的话,setA
首先获取锁,在此方法还未释放锁的情况下,调用setB
也需要获取相同的对象锁,此时会造成死锁。
公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序获取锁,非公平锁则是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。非公平锁的优点在于吞吐量比公平锁大,但是也有可能会造成优先级反转或者饥饿现象。
Java中ReetrantLock可以通过构造函数指定该锁是否是公平锁,默认是非公平锁。而synchronized则是非公平锁。
分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁。比如ConcurrentHashMap
,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
ConcurrentHashMap中的分段锁封装为Segment,它本身也是类似于HashMap的结构,其内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
当需要put元素的时候,并不是对整个HashMap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。
偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。
自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
13 ThreadLocal是什么?
这道题想考察什么?
是否了解ThreadLocal与真实场景使用,是否熟悉ThreadLocal
考察的知识点
ThreadLocal的概念在项目中使用与基本知识
考生应该如何回答
ThreadLocal提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("享学");
System.out.println("主线程获取变量:"+threadLocal.get());
Thread thread = new Thread() {
@Override
public void run() {
super.run();
System.out.println("子线程获取变量:"+ threadLocal.get());
threadLocal.set("教育");
System.out.println("子线程获取变量:"+ threadLocal.get());
}
};
在上述代码中,主线程输出:享学,子线程第一次输出:null,第二次输出教育。ThreadLocal相当于提供了一种线程隔离,将变量与线程相绑定。
set
通过ThreadLocal#set
设置线程本地变量,set的实现为:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
通过Thread.currentThread()方法获取了当前的线程引用,并传给了getMap(Thread)方法获取一个ThreadLocalMap的实例。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
可以看到getMap(Thread)方法直接返回Thread实例的成员变量threadLocals。它的定义在Thread内部:
public class Thread implements Runnable {
ThreadLocal.ThreadLocalMap threadLocals = null;
}
每个Thread里面都有一个ThreadLocal.ThreadLocalMap成员变量,也就是说每个线程通过ThreadLocal.ThreadLocalMap与ThreadLocal相绑定,这样可以确保每个线程访问到变量的都是本线程自己的。
获取了ThreadLocalMap实例以后,如果它不为空则调用ThreadLocalMap.ThreadLocalMap 的set方法设值;若为空则调用ThreadLocal 的createMap方法new一个ThreadLocalMap实例并赋给Thread.threadLocals。
void createMap(Thread t, T firstValue) {
// this = ThreadLocal
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
get
而ThreadLocal 的 get 方法,源码如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();
}
同样通过Thread.currentThread()方法获取了当前的线程引用,并传给了getMap(Thread)方法获取一个ThreadLocalMap的实例。 而如果从ThreadLocalMap未能找到当前线程的变量则返回setInitialValue
。
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
在setInitialValue中首先调用 initialValue()方法来获得一个value,然后执行ThreadLocal#set
同样的处理并返回这个value,也就是说可以通过重写ThreadLocal的initialValue方法能够实现在set变量值之前,使用get获取的就是这个initialValue返回的结果。
ThreadLocal<String> threadLocal = new ThreadLocal<String>(){
@Nullable
@Override
protected String initialValue() {
return "享学";
}
};
// 享学
String value = threadLocal.get();
在set/get中其实就是借助ThreadLocalMap实现线程与本地变量的绑定与获取。每个线程都有自己的一个ThreadLocalMap,ThreadLocalMap是一个映射集合,以ThreadLocal为key。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Mln0Pz7y-1688439193379)(images\threadlocalmap.png)]
ThreadLocal简化的伪代码为:
class Thread extends Thread {
ThreadLocalMap threadLocals;
}
class ThreadLocal<T> {
public void set(T t) {
Thread thread = Thread.currentThread();
thread.threadLocals.put(this, t);
}
public T get() {
Thread thread = Thread.currentThread();
thread.threadLocals.get(this);
}
}
14 Java多线程对同一个对象进行操作(字节跳动)
这道题想考察什么?
是否了解Java多线程对同一个对象进行操作与真实场景使用,是否熟悉Java多线程对同一个对象进行操作?
考察的知识点
Java多线程对同一个对象进行操作的概念在项目中使用与基本知识
考生应该如何回答
在多线程环境下,多个线程操作同一对象,本质上就是线程安全问题。因此为了应对线程安全需要对多线程操作的对象加锁。
例如当我们遇到需求:实现三个窗口同时出售20张票。
程序分析:
1、票数要使用一个静态的值。
2、为保证不会出现卖出同一张票,要使用同步锁。
3、设计思路:创建一个站台类Station,继承Thread,重写run方法,在run方法内部执行售票操作。
售票要使用同步锁:即有一个站台卖这张票时,其他站台要等待这张票卖完才能继续卖票!
package com.multi_thread;
//站台类
public class Station extends Thread {
// 通过构造方法给线程名字赋值
public Station(String name) {
super(name);// 给线程起名字
}
// 为了保持票数的一直,票数要静态
static int tick = 20;
// 创建一个静态钥匙
static Object ob = "aa";// 值是任意的
@Override
public void run() {
while (tick > 0) {
// 这个很重要,必须使用一个锁,进去的人会把钥匙拿在手上,出来后把钥匙让出来
synchronized (ob) {
if (tick > 0) {
System.out.println(getName() + "卖出了第" + tick + "张票");
tick--;
} else {
System.out.println("票卖完了");
}
}
try {
// 休息一秒钟
sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
package com.multi_thread;
public class MainClass {
// java多线程同步所的使用
// 三个售票窗口同时出售10张票
public static void main(String[] args) {
// 实例化站台对象,并为每一个站台取名字
Station station1 = new Station("窗口1");
Station station2 = new Station("窗口2");
Station station3 = new Station("窗口3");
// 让每一个站台对象各自开始工作
station1.start();
station2.start();
station3.start();
}
}
程序运行结果:
窗口1卖出了第20张票
窗口3卖出了第19张票
窗口2卖出了第18张票
窗口2卖出了第17张票
窗口3卖出了第16张票
窗口1卖出了第15张票
窗口1卖出了第14张票
窗口3卖出了第13张票
窗口2卖出了第12张票
窗口1卖出了第11张票
窗口3卖出了第10张票
窗口2卖出了第9张票
窗口1卖出了第8张票
窗口3卖出了第7张票
窗口2卖出了第6张票
窗口1卖出了第5张票
窗口3卖出了第4张票
窗口2卖出了第3张票
窗口3卖出了第2张票
窗口1卖出了第1张票
15 线程生命周期,线程可以多次调用start吗? 会出现什么问题? 为什么不能多次调用start?
这道题想考察什么?
是否了解Java并发线程的相关知识
考察的知识点
线程生命周期及变化
考生应该如何回答
线程生命周期中重要的状态
- 新建 New;
- 就绪 Runnable
- 运行 Running
- 阻塞 Blocked
- 死亡 Dead
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wYnmnG7B-1688439193381)(images/lifecycle.webp)]
新建 new
public class CThread extends Thread{
@Override
public void run() {
}
}
//新建就是new出对象
CThread thread = new CThread();
当程序使用new关键字创建了一个线程之后,该线程就处于一个新建状态(初始状态),此时它和其他Java对象一样,仅仅由Java虚拟机为其分配了内存,并初始化了其成员变量值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
就绪 Runnable
当线程对象调用了Thread.start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态的线程并没有开始运行,它只是表示该线程可以运行了。从start()源码中看出,start后添加到了线程列表中,接着在native层添加到VM中,至于该线程何时开始运行,取决于JVM里线程调度器的调度(如果OS调度选中了,就会进入到运行状态)。回看一下下面start方法源码:
public synchronized void start() {
/**
* This method is not invoked for the main method thread or "system"
* group threads created/set up by the VM. Any new functionality added
* to this method in the future may have to also be added to the VM.
*
* A zero status value corresponds to state "NEW".
*/
// Android-changed: throw if 'started' is true
if (threadStatus != 0 || started)
throw new IllegalThreadStateException();
/* Notify the group that this thread is about to be started
* so that it can be added to the group's list of threads
* and the group's unstarted count can be decremented. */
//通知组此线程即将启动,以便将其添加到组的线程列表中,并且可以减少组的未启动计数。
group.add(this);
started = false;
try {
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* do nothing. If start0 threw a Throwable then
it will be passed up the call stack */
}
}
}
C/C++中的nativeCreate的源码
static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread,
jlong stack_size, jboolean daemon) {
Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}
C/C++中的CreateNativeThread
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
Thread* self = static_cast<JNIEnvExt*>(env)->self;
Runtime* runtime = Runtime::Current();
...
Thread* child_thread = new Thread(is_daemon);
child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
stack_size = FixStackSize(stack_size);
env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer,
reinterpret_cast<jlong>(child_thread));
std::unique_ptr<JNIEnvExt> child_jni_env_ext(
JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM()));
int pthread_create_result = 0;
if (child_jni_env_ext.get() != nullptr) {
pthread_t new_pthread;
pthread_attr_t attr;
child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
//创建线程
pthread_create_result = pthread_create(&new_pthread,
&attr, Thread::CreateCallback, child_thread);
if (pthread_create_result == 0) {
child_jni_env_ext.release();
return;
}
}
...
}
C/C++中的pthread_create,pthread_create的分析暂时不分析,涉及到Linux知识代深入了解再分析,先说说pthread_create的参数
- 原型:int pthread_create((pthread_t thread, pthread_attr_t *attr, void *(start_routine)(void *), void *arg)
- 头文件:#include
- 输入参数:thread:线程标识符; attr:线程属性设置; start_routine:线程函数的起始地址; - arg:传递给start_routine的参数;
- 返回值:成功则返回0;出错则返回-1。
- 功能:创建线程,并调用线程起始地址所指向的函数start_routine。
运行 Running
如果处于就绪状态的线程获得了CPU资源,就开始执行run方法的线程执行体,则该线程处于运行状态。run方法的那里呢?其实run也是在native线程中。源码如下:
status_t Thread::run(const char* name, int32_t priority, size_t stack)
{
Mutex::Autolock _l(mLock);
//保证只会启动一次
if (mRunning) {
return INVALID_OPERATION;
}
...
mRunning = true;
bool res;
if (mCanCallJava) {
//还能调用Java代码的Native线程
res = createThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
} else {
//只能调用C/C++代码的Native线程
res = androidCreateRawThreadEtc(_threadLoop,
this, name, priority, stack, &mThread);
}
if (res == false) {
...//清理
return UNKNOWN_ERROR;
}
return NO_ERROR;
}
mCanCallJava在Thread对象创建时,在构造函数中默认设置mCanCallJava=true.
- 当mCanCallJava=true,则代表创建的是不仅能调用C/C++代码,还能能调用Java代码的Native线程
- 当mCanCallJava=false,则代表创建的是只能调用C/C++代码的Native线程。
关于createThreadEtc和androidCreateRawThreadEtc方法都不一一列出来了,感兴趣的自已查检源码了解。
从start方法进入nativeCreate经过层层调用,最终都会进入clone系统调用,这是linux创建线程或进程的通用接口。Native线程中是否可以执行Java代码的区别,在于通过javaThreadShell()方法从而实现在_threadLoop()执行前后增加分别将当前线程增加hook到虚拟机和从虚拟机移除的功能。调用过程,顺序为:
1.Thread.run
2.createThreadEtc
3.androidCreateThreadEtc
4.javaCreateThreadEtc
5.androidCreateRawThreadEtc
6.javaThreadShell
7.javaAttachThread
8._threadLoop
9.javaDetachThread
阻塞 Blocked
阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况大概三种:
1、等待阻塞:运行的线程执行wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁)
2、同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
3、其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)。
线程睡眠:Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为就绪(Runnable)状态。sleep()平台移植性好。
线程等待:Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。唤醒线程后,就转为就绪(Runnable)状态。
线程让步:Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
线程加入:join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
线程I/O:线程执行某些IO操作,因为等待相关的资源而进入了阻塞状态。比如说监听system.in,但是尚且没有收到键盘的输入,则进入阻塞状态。
线程唤醒:Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程,选择是任意性的,并在对实现做出决定时发生。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。
死亡 Dead
线程会以以下三种方式之一结束,结束后就处于死亡状态:
- run()方法执行完成,线程正常结束。
- 线程抛出一个未捕获的Exception或Error。
- 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。
线程多次启动
Java线程是不允许启动多次的,第二次调用必然会抛出IllegalThreadStateException。 根据线程生命周期可知,线程初始状态为NEW,此状态不能由其他状态转变而来。
文末
整理不易,白嫖太易!有需要的读者朋友们可以关注一下。以上都可分享~