# 虎牙 Java 面试
大家好,我是小林。
虎牙属于互联网中厂,专注的是直播领域,比较出圈的是游戏领域的直播了,不少职业选手都在虎牙直播,曾经在校的时候,我也经常在虎牙看 LOL 游戏直播,不过工作之后就看的比较少,短视频平台的出现还是对以前的直播平台影响比较大。
我看了虎牙 2024 年第一~第三季度财报,用户数和利润整体还是增长的状态,每个季度的总收入在 15 亿元,直播收入在 12 亿左右,占比大头,基本盘还是没有太大的问题。
我们来看看虎牙校招薪资如何?
收集了几个虎牙 25 届开发岗位校招薪资的情况,虎牙办公地点在广州:
- 后台开发岗位:22k x 16 + 0.6k x 12(每个月 600 饭补)= 35w
- 客户端开发岗位:21k x 16 + 0.6k x 12(每个月 600 饭补)= 33w
- 运维开发岗位:20k x 16 + 0.6k x 12(每个月 600 饭补)= 32w
之前看到有读者说,他同学收到了两年前校招投递的虎牙的感谢信,大概率是之前面试完,流程卡住了,没人去推进或者结束流程,这一卡就卡了 2 年了,笑哭。
这次来分享一位同学虎牙的Java 开发岗位的一面面经,问题虽然不多,但是主要都是拷打并发编程的,而且问题简直就是一环扣一环,步步紧逼,还是蛮有压力的面试。
最开始从并发理论切入,刚把基础概念捋清楚,马上就跳到并发工具上了。讲工具原理的时候,好不容易把 CAS 搞明白点儿,结果紧接着又得深挖 CAS 以及 AQS 这些底层技术,脑子还在高速运转消化呢,下一个问题就来了,直接问怎么用 AQS 去实现锁,这还不算完,还得把 ReentranLock 的实现原理一股脑儿地背出来。本以为这就结束了,谁知道又被要求用 MySQL 来实现,而且各个细节都得描述得清清楚楚,这一通下来,人都被问懵了,脑袋里跟一团乱麻似的,压力山大啊!
# 虎牙一面
# 怎么理解并发问题?
并发是指在同一时间段内,多个任务交替执行。在单处理器系统中,实际上这些任务是通过快速切换轮流使用处理器资源,看起来像是同时执行。而在多处理器系统中,多个任务可以真正地同时在不同的处理器核心上执行。
当多个线程同时访问和修改共享资源时,由于线程执行的顺序是不确定的,可能会导致最终的结果依赖于线程执行的顺序,这种情况就称为竞争条件。例如,在一个简单的计数器程序中,如果多个线程同时对计数器进行自增操作,由于线程切换的不确定性,可能会出现某些自增操作丢失的情况,导致最终的计数值与预期不符。
要保证多线程的允许是安全,不要出现数据竞争造成的数据混乱的问题。
Java的线程安全在三个方面体现:
- 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic和synchronized关键字来确保原子性;
- 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
- 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。
# 什么情况会产生死锁问题?如何解决?
死锁只有同时满足以下四个条件才会发生:
- 互斥条件:互斥条件是指多个线程不能同时使用同一个资源。
- 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
- 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
- 环路等待条件:环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链。
例如,线程 A 持有资源 R1 并试图获取资源 R2,而线程 B 持有资源 R2 并试图获取资源 R1,此时两个线程相互等待对方释放资源,从而导致死锁。
public class DeadlockExample {
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
synchronized (resource1) {
System.out.println("Thread A acquired resource1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource2) {
System.out.println("Thread A acquired resource2");
}
}
});
Thread threadB = new Thread(() -> {
synchronized (resource2) {
System.out.println("Thread B acquired resource2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (resource1) {
System.out.println("Thread B acquired resource1");
}
}
});
threadA.start();
threadB.start();
}
}
避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。
那什么是资源有序分配法呢?线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。
# 讲讲 Java 的一些并发工具?
Java 中一些常用的并发工具,它们位于 java.util.concurrent
包中,常见的有:
- CountDownLatch:CountDownLatch 是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。它使用一个计数器进行初始化,调用
countDown()
方法会使计数器减一,当计数器的值减为 0 时,等待的线程会被唤醒。可以把它想象成一个倒计时器,当倒计时结束(计数器为 0)时,等待的事件就会发生。示例代码:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
int numberOfThreads = 3;
CountDownLatch latch = new CountDownLatch(numberOfThreads);
// 创建并启动三个工作线程
for (int i = 0; i < numberOfThreads; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 正在工作");
try {
Thread.sleep(1000); // 模拟工作时间
} catch (InterruptedException e) {
e.printStackTrace();
}
latch.countDown(); // 完成工作,计数器减一
System.out.println(Thread.currentThread().getName() + " 完成工作");
}).start();
}
System.out.println("主线程等待工作线程完成");
latch.await(); // 主线程等待,直到计数器为 0
System.out.println("所有工作线程已完成,主线程继续执行");
}
}
- CyclicBarrier:CyclicBarrier 允许一组线程互相等待,直到到达一个公共的屏障点。当所有线程都到达这个屏障点后,它们可以继续执行后续操作,并且这个屏障可以被重置循环使用。与
CountDownLatch
不同,CyclicBarrier
侧重于线程间的相互等待,而不是等待某些操作完成。示例代码:
import java.util.concurrent.CyclicBarrier;
public class CyclicBarrierExample {
public static void main(String[] args) {
int numberOfThreads = 3;
CyclicBarrier barrier = new CyclicBarrier(numberOfThreads, () -> {
System.out.println("所有线程都到达了屏障,继续执行后续操作");
});
for (int i = 0; i < numberOfThreads; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 正在运行");
Thread.sleep(1000); // 模拟运行时间
barrier.await(); // 等待其他线程
System.out.println(Thread.currentThread().getName() + " 已经通过屏障");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
- Semaphore:Semaphore 是一个计数信号量,用于控制同时访问某个共享资源的线程数量。通过
acquire()
方法获取许可,使用release()
方法释放许可。如果没有许可可用,线程将被阻塞,直到有许可被释放。可以用来限制对某些资源(如数据库连接池、文件操作等)的并发访问量。代码如下:
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(2); // 允许 2 个线程同时访问
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
semaphore.acquire(); // 获取许可
System.out.println(Thread.currentThread().getName() + " 获得了许可");
Thread.sleep(2000); // 模拟资源使用
System.out.println(Thread.currentThread().getName() + " 释放了许可");
semaphore.release(); // 释放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
}
}
- Future 和 Callable:Callable 是一个类似于
Runnable
的接口,但它可以返回结果,并且可以抛出异常。Future 用于表示一个异步计算的结果,可以通过它来获取Callable
任务的执行结果或取消任务。代码如下:
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class FutureCallableExample {
public static void main(String[] args) throws Exception {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Callable<Integer> callable = () -> {
System.out.println(Thread.currentThread().getName() + " 开始执行 Callable 任务");
Thread.sleep(2000); // 模拟耗时操作
return 42; // 返回结果
};
Future<Integer> future = executorService.submit(callable);
System.out.println("主线程继续执行其他任务");
try {
Integer result = future.get(); // 等待 Callable 任务完成并获取结果
System.out.println("Callable 任务的结果: " + result);
} catch (Exception e) {
e.printStackTrace();
}
executorService.shutdown();
}
}
- ConcurrentHashMap:ConcurrentHashMap 是一个线程安全的哈希表,它允许多个线程同时进行读操作,在一定程度上支持并发的修改操作,避免了
HashMap
在多线程环境下需要使用synchronized
或Collections.synchronizedMap()
进行同步的性能问题。代码如下:
import java.util.concurrent.ConcurrentHashMap;
public class ConcurrentHashMapExample {
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key1", 1);
map.put("key2", 2);
// 并发读操作
map.forEach((key, value) -> System.out.println(key + ": " + value));
// 并发写操作
map.computeIfAbsent("key3", k -> 3);
}
}
# 介绍一下 ConcurrentHashMap 并发原理?
JDK 1.7 ConcurrentHashMap
在 JDK 1.7 中它使用的是数组加链表的形式实现的,而数组又分为:大数组 Segment 和小数组 HashEntry。 Segment 是一种可重入锁(ReentrantLock),在 ConcurrentHashMap 里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。
JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。
JDK 1.8 ConcurrentHashMap
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:
JDK 1.8 ConcurrentHashMap JDK 1.8 ConcurrentHashMap 主要通过 volatile + CAS 或者 synchronized 来实现的线程安全的。添加元素时首先会判断容器是否为空:
如果为空则使用 volatile 加 CAS 来初始化
如果容器不为空,则根据存储的元素计算该位置是否为空。
如果根据存储的元素计算结果为空,则利用 CAS 设置该节点;
如果根据存储的元素计算结果不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能保证并发访问时的线程安全了。
如果把上面的执行用一句话归纳的话,就相当于是ConcurrentHashMap通过对头结点加锁来保证线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的性能就提高了。
而且 JDK 1.8 使用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询性能也得到了很大的提升,从之前的 O(n) 优化到了 O(logn) 的时间复杂度。
# CAS 和 AQS 有什么关系?
CAS 和 AQS 两者的区别:
- CAS 是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期值(A)和新值(B)。CAS 操作的逻辑是,如果内存位置 V 的值等于预期值 A,则将其更新为新值 B,否则不做任何操作。整个过程是原子性的,通常由硬件指令支持,如在现代处理器上,
cmpxchg
指令可以实现 CAS 操作。 - AQS 是一个用于构建锁和同步器的框架,许多同步器如
ReentrantLock
、Semaphore
、CountDownLatch
等都是基于 AQS 构建的。AQS 使用一个volatile
的整数变量state
来表示同步状态,通过内置的FIFO
队列来管理等待线程。它提供了一些基本的操作,如acquire
(获取资源)和release
(释放资源),这些操作会修改state
的值,并根据state
的值来判断线程是否可以获取或释放资源。AQS 的acquire
操作通常会先尝试获取资源,如果失败,线程将被添加到等待队列中,并阻塞等待。release
操作会释放资源,并唤醒等待队列中的线程。
CAS 和 AQS 两者的联系:
- CAS 为 AQS 提供原子操作支持:AQS 内部使用 CAS 操作来更新
state
变量,以实现线程安全的状态修改。在acquire
操作中,当线程尝试获取资源时,会使用 CAS 操作尝试将state
从一个值更新为另一个值,如果更新失败,说明资源已被占用,线程会进入等待队列。在release
操作中,当线程释放资源时,也会使用 CAS 操作将state
恢复到相应的值,以保证状态更新的原子性。
# 如何用 AQS 实现一个可重入的公平锁?
AQS 实现一个可重入的公平锁的详细步骤:
- 继承 AbstractQueuedSynchronizer:创建一个内部类继承自
AbstractQueuedSynchronizer
,重写tryAcquire
、tryRelease
、isHeldExclusively
等方法,这些方法将用于实现锁的获取、释放和判断锁是否被当前线程持有。 - 实现可重入逻辑:在
tryAcquire
方法中,检查当前线程是否已经持有锁,如果是,则增加锁的持有次数(通过state
变量);如果不是,尝试使用 CAS操作来获取锁。 - 实现公平性:在
tryAcquire
方法中,按照队列顺序来获取锁,即先检查等待队列中是否有线程在等待,如果有,当前线程必须进入队列等待,而不是直接竞争锁。 - 创建锁的外部类:创建一个外部类,内部持有
AbstractQueuedSynchronizer
的子类对象,并提供lock
和unlock
方法,这些方法将调用AbstractQueuedSynchronizer
子类中的方法。
代码如下:
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
public class FairReentrantLock {
private static class Sync extends AbstractQueuedSynchronizer {
// 判断锁是否被当前线程持有
protected boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}
// 尝试获取锁
protected boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 公平性检查:检查队列中是否有前驱节点,如果有,则当前线程不能获取锁
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) {
// 可重入逻辑:如果是当前线程持有锁,则增加持有次数
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
return false;
}
// 尝试释放锁
protected boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread()!= getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException();
}
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
// 提供一个条件变量,用于实现更复杂的同步需求,这里只是简单实现
ConditionObject newCondition() {
return new ConditionObject();
}
}
private final Sync sync = new Sync();
// 加锁方法
public void lock() {
sync.acquire(1);
}
// 解锁方法
public void unlock() {
sync.release(1);
}
// 判断当前线程是否持有锁
public boolean isLocked() {
return sync.isHeldExclusively();
}
// 提供一个条件变量,用于实现更复杂的同步需求,这里只是简单实现
public Condition newCondition() {
return sync.newCondition();
}
}
# 如何用 MySQL 实现一个可重入的锁?
创建一个保存锁记录的表:
CREATE TABLE `lock_table` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
//该字段用于存储锁的名称,作为锁的唯一标识符。
`lock_name` VARCHAR(255) NOT NULL,
// holder_thread该字段存储当前持有锁的线程的名称,用于标识哪个线程持有该锁。
`holder_thread` VARCHAR(255),
// reentry_count 该字段存储锁的重入次数,用于实现锁的可重入性
`reentry_count` INT DEFAULT 0
);
加锁的实现逻辑
开启事务
执行 SQL
SELECT holder_thread, reentry_count FROM lock_table WHERE lock_name =? FOR UPDATE
,查询是否存在该记录:如果记录不存在,则直接加锁,执行
INSERT INTO lock_table (lock_name, holder_thread, reentry_count) VALUES (?,?, 1)
如果记录存在,且持有者是同一个线程,则可冲入,增加重入次数,执行
UPDATE lock_table SET reentry_count = reentry_count + 1 WHERE lock_name =?
提交事务
解锁的逻辑:
开启事务
执行 SQL
SELECT holder_thread, reentry_count FROM lock_table WHERE lock_name =? FOR UPDATE
,查询是否存在该记录:如果记录存在,且持有者是同一个线程,且可重入数大于 1 ,则减少重入次数
UPDATE lock_table SET reentry_count = reentry_count - 1 WHERE lock_name =?
如果记录存在,且持有者是同一个线程,且可重入数小于等于 0 ,则完全释放锁,
DELETE FROM lock_table WHERE lock_name =?
提交事务
对了,最新的互联网大厂后端面经都会在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。