一天一道Java面试题,坚持三个月,菜鸟变大佬(并发篇)

44 阅读8分钟

Java 中的高并发问题:解析、面试题与实战

并发是指多个任务在同一时间段内执行,而并行则是指多个任务在同一时刻同时执行。在多核 CPU 环境下,并行是并发的一种特殊情况。

在 Java 中,并发编程通常涉及到以下几个核心概念:

  • 线程:Java 中最小的执行单元,Thread 类和 Runnable 接口是实现线程的基础。
  • 同步:多个线程访问共享资源时,确保数据一致性和线程安全。
  • :用来控制线程对共享资源的访问,防止数据竞争(Data Race)。
  • 线程池:复用线程资源,提高并发性能。
  • 无锁编程:通过原子操作(Atomic)和不可变对象实现高效的并发。

一、高并发常见问题概述

1. 线程安全问题

多个线程同时访问共享资源时,可能导致数据不一致或逻辑错误。例如在银行转账场景中,若两个线程同时对同一账户进行扣款和入账操作,且未进行同步处理,就可能出现账户金额异常的情况。这是因为线程在读取和修改数据时,可能会受到其他线程的干扰。

2. 性能瓶颈

高并发环境下,过多的线程竞争有限的资源,如 CPU、内存、数据库连接等,会导致上下文切换频繁,降低系统整体性能。此外,I/O 操作(如网络请求、磁盘读写)的等待时间也会成为性能瓶颈,大量线程在等待 I/O 完成时处于阻塞状态,无法充分利用 CPU 资源。

3. 死锁问题

当多个线程相互持有对方所需的资源,且都不释放自己已持有的资源时,就会形成死锁。例如,线程 A 持有资源 X 并等待资源 Y,线程 B 持有资源 Y 并等待资源 X,此时两个线程都无法继续执行,导致系统部分功能无法正常运行。

二、经典面试题与实际场景解析

面试题 1:如何解决多线程访问共享资源的线程安全问题?

实际场景:在一个电商库存管理系统中,多个订单处理线程可能同时对商品库存进行减扣操作,如果不进行同步控制,可能会出现超卖现象。

解决方案

  1. 使用 synchronized 关键字
public class Inventory {
    // 用于存储商品库存数量
    private int stock;
    // 构造函数,初始化库存数量
    public Inventory(int stock) {
        this.stock = stock;
    }
    // 使用synchronized修饰方法,将该方法变为同步方法
    // 确保同一时间只有一个线程能进入该方法,从而保证对stock的操作是线程安全的
    public synchronized boolean deduct(int quantity) {
        // 判断当前库存是否足够扣减
        if (stock >= quantity) {
            // 库存足够,进行扣减操作
            stock -= quantity;
            return true;
        }
        return false;
    }
}
  1. 使用 Lock 接口
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class InventoryWithLock {
    // 用于存储商品库存数量
    private int stock;
    // 创建一个可重入锁实例,用于控制对库存操作的线程同步
    private Lock lock = new ReentrantLock();
    // 构造函数,初始化库存数量
    public InventoryWithLock(int stock) {
        this.stock = stock;
    }
    public boolean deduct(int quantity) {
        // 加锁,获取锁资源,确保同一时间只有一个线程能执行后续操作
        lock.lock();
        try {
            // 判断当前库存是否足够扣减
            if (stock >= quantity) {
                // 库存足够,进行扣减操作
                stock -= quantity;
                return true;
            }
            return false;
        } finally {
            // 无论try块中是否发生异常,都要在最后释放锁
            // 确保其他线程有机会获取锁并执行操作
            lock.unlock();
        }
    }
}
  1. 使用原子类
import java.util.concurrent.atomic.AtomicInteger;
public class InventoryWithAtomic {
    // 使用AtomicInteger类来表示商品库存数量,它是线程安全的
    private AtomicInteger stock;
    // 构造函数,初始化库存数量
    public InventoryWithAtomic(int stock) {
        this.stock = new AtomicInteger(stock);
    }
    public boolean deduct(int quantity) {
        // 使用getAndAdd方法进行原子性减扣操作
        // 该方法会先返回当前值(旧值),然后将指定的值(-quantity)与当前值相加并更新
        int oldValue;
        do {
            // 获取当前库存值
            oldValue = stock.get();
            // 判断当前库存是否足够扣减
            if (oldValue < quantity) {
                return false;
            }
        } while (!stock.compareAndSet(oldValue, oldValue - quantity));
        // compareAndSet方法会比较当前值是否等于预期值(oldValue)
        // 如果相等,则将当前值更新为新值(oldValue - quantity),并返回true;否则返回false
        return true;
    }
}

面试题 2:如何优化高并发系统的性能?

实际场景:一个在线教育平台的直播系统,在上课高峰期会有大量用户同时进入直播间,系统需要快速处理用户请求并保证视频流的稳定传输。

优化策略

  1. 合理使用线程池
import java.util.concurrent.*;
public class LiveRoomThreadPool {
    // 根据服务器CPU核心数动态设置核心线程数,这里假设服务器为4核
    // 加1是为了充分利用CPU资源,保证在有线程进行非CPU操作时,CPU不会空闲
    private static final int CORE_POOL_SIZE = 4 + 1;
    // 最大线程数根据业务峰值预估,适当增大以应对突发的高并发请求
    private static final int MAXIMUM_POOL_SIZE = 4 * 2 + 1;
    // 空闲线程存活时间,当线程池中的线程数量大于核心线程数时
    // 空闲线程在超过该时间后会被销毁,用于回收闲置资源
    private static final long KEEP_ALIVE_TIME = 10L;
    // 时间单位为秒,与KEEP_ALIVE_TIME配合使用
    private static final TimeUnit UNIT = TimeUnit.SECONDS;
    // 任务队列采用有界队列,容量根据预估的请求量设置
    // 避免队列无限增长导致内存耗尽
    private static final BlockingQueue<Runnable> WORK_QUEUE = new ArrayBlockingQueue<>(1000);
    // 自定义线程工厂,设置线程名称前缀
    // 方便在调试和监控时快速识别线程所属的线程池
    private static final ThreadFactory THREAD_FACTORY = new ThreadFactoryBuilder()
           .setNameFormat("live-room-thread-pool-%d")
           .build();
    // 拒绝策略采用CallerRunsPolicy
    // 当任务队列满且线程数达到最大时,由调用线程(提交任务的线程)来处理任务
    // 避免因任务过多导致系统崩溃
    private static final RejectedExecutionHandler HANDLER = new ThreadPoolExecutor.CallerRunsPolicy();
    public static ThreadPoolExecutor getThreadPool() {
        return new ThreadPoolExecutor(
                CORE_POOL_SIZE,
                MAXIMUM_POOL_SIZE,
                KEEP_ALIVE_TIME,
                UNIT,
                WORK_QUEUE,
                THREAD_FACTORY,
                HANDLER
        );
    }
}
  1. 异步处理任务:对于一些非实时性的任务,如用户观看直播后的学习记录保存、课后作业提交等,可以使用CompletableFuture进行异步处理,避免阻塞主线程。
import java.util.concurrent.CompletableFuture;
public class AsyncTask {
    public static void saveStudyRecord(String userId) {
        // 使用CompletableFuture.runAsync方法提交一个异步任务
        // 该任务会在一个新的线程中执行,不会阻塞主线程
        CompletableFuture.runAsync(() -> {
            // 模拟保存学习记录的操作,如写入数据库
            // 这里通过线程休眠来模拟耗时操作
            try {
                Thread.sleep(1000);
                System.out.println("学习记录已保存,用户ID:" + userId);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}
  1. 缓存技术:使用缓存(如 Redis)存储热点数据,如直播间的课程介绍、讲师信息等,减少对数据库的频繁访问,提高响应速度。

面试题 3:如何检测和避免死锁?

实际场景:在一个任务调度系统中,多个任务可能需要获取多个资源才能执行,若资源分配不当,可能会引发死锁。

检测与避免方法

  1. 死锁检测:可以通过 JDK 自带的jconsole或jvisualvm工具来检测死锁。这些工具能够监控线程状态,当检测到死锁时,会给出相关提示。
  1. 避免死锁
    • 资源有序分配:对资源进行编号,线程按照固定的顺序获取资源。例如,线程 A 和线程 B 都需要资源 X 和资源 Y,规定先获取资源 X 再获取资源 Y,这样就不会出现相互等待的情况。
    • 设置超时时间:在使用Lock接口获取锁时,设置超时时间,如果在规定时间内未获取到锁,则放弃本次尝试,避免无限等待。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class DeadlockAvoidance {
    // 创建两个可重入锁实例,模拟两个资源
    private Lock lock1 = new ReentrantLock();
    private Lock lock2 = new ReentrantLock();
    public void method1() {
        boolean gotLock1 = false;
        boolean gotLock2 = false;
        try {
            // 尝试获取lock1锁,设置等待时间为1秒
            // 如果在1秒内获取到锁,则返回true,否则返回false
            gotLock1 = lock1.tryLock(1, java.util.concurrent.TimeUnit.SECONDS);
            if (gotLock1) {
                try {
                    // 如果成功获取lock1锁,再尝试获取lock2锁,同样设置等待时间为1秒
                    gotLock2 = lock2.tryLock(1, java.util.concurrent.TimeUnit.SECONDS);
                    if (gotLock2) {
                        // 成功获取两个锁,执行任务
                        System.out.println("成功获取锁,执行任务");
                    }
                } finally {
                    // 无论是否成功获取lock2锁,都要释放lock2锁
                    if (gotLock2) {
                        lock2.unlock();
                    }
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 无论是否成功获取lock1锁,都要释放lock1锁
            if (gotLock1) {
                lock1.unlock();
            }
        }
    }
}

三、总结与建议

Java 中的高并发问题复杂多样,需要开发者深入理解并发编程的原理,并结合实际场景灵活运用各种技术和工具。在解决高并发问题时,应从线程安全、性能优化、死锁避免等多个方面综合考虑。同时,多进行代码实践和性能测试,不断积累经验,才能在面对高并发挑战时游刃有余。通过对经典面试题的学习和分析,希望你能更好地掌握高并发编程知识,在实际开发和面试中取得优异表现。