# 虎牙 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 年了,笑哭。

img

这次来分享一位同学虎牙的Java 开发岗位的一面面经,问题虽然不多,但是主要都是拷打并发编程的,而且问题简直就是一环扣一环,步步紧逼,还是蛮有压力的面试。

img

最开始从并发理论切入,刚把基础概念捋清楚,马上就跳到并发工具上了。讲工具原理的时候,好不容易把 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 总是以相同的顺序申请自己想要的资源。

null

# 讲讲 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 在多线程环境下需要使用 synchronizedCollections.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 是一个链表结构的元素。

null

JDK 1.7 ConcurrentHashMap 分段锁技术将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。

JDK 1.8 ConcurrentHashMap

在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但因为它的底层实现是数组 + 链表的形式,所以在数据比较多的情况下访问是很慢的,因为要遍历整个链表,而 JDK 1.8 则使用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的实现,具体实现结构如下:

null

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 是一个用于构建锁和同步器的框架,许多同步器如 ReentrantLockSemaphoreCountDownLatch 等都是基于 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 实现一个可重入的公平锁的详细步骤:

  1. 继承 AbstractQueuedSynchronizer:创建一个内部类继承自 AbstractQueuedSynchronizer,重写 tryAcquiretryReleaseisHeldExclusively 等方法,这些方法将用于实现锁的获取、释放和判断锁是否被当前线程持有。
  2. 实现可重入逻辑:在 tryAcquire 方法中,检查当前线程是否已经持有锁,如果是,则增加锁的持有次数(通过 state 变量);如果不是,尝试使用 CAS操作来获取锁。
  3. 实现公平性:在 tryAcquire 方法中,按照队列顺序来获取锁,即先检查等待队列中是否有线程在等待,如果有,当前线程必须进入队列等待,而不是直接竞争锁。
  4. 创建锁的外部类:创建一个外部类,内部持有 AbstractQueuedSynchronizer 的子类对象,并提供 lockunlock 方法,这些方法将调用 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 =?

  • 提交事务


对了,最新的互联网大厂后端面经都会在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。

img