2、Java并发面试题(一)- 挑战高薪必备

78 阅读11分钟

在当今竞争激烈的软件开发领域,尤其是在 Java 编程的求职过程中,Java 并发面试题的重要性不言而喻。

随着业务需求的日益复杂和系统规模的不断扩大,并发编程在实际项目中的应用越来越广泛。能够熟练掌握并发知识,并在面试中对相关问题给出准确、深入的回答,是展现技术实力和解决复杂问题能力的关键。

掌握 Java 并发面试题,不仅能证明您对 Java 语言的精通程度,还能展示您在处理高并发、多线程环境下的编程技巧和思维方式。这对于您获得理想的工作机会,提升职业发展空间具有至关重要的作用。

1、什么是线程安全问题

线程安全问题指的是在多线程环境下,多个线程同时访问和操作共享资源时,可能导致数据不一致、程序逻辑错误或其他不可预测的结果。

例如,假设有一个共享的整数变量 count ,多个线程同时对其进行递增操作。如果没有采取适当的同步措施,可能会出现线程 A 读取了 count 的值,还没来得及进行递增操作,线程 B 也读取了 count 的值,然后线程 A 和线程 B 分别进行递增并写入,最终导致 count 的值增加的次数少于预期。

再比如,一个共享的对象 User ,多个线程同时修改其属性值,可能会导致属性值被混乱地修改,从而破坏了对象的完整性和一致性。

线程安全问题是并发编程中需要重点关注和解决的难题,只有通过合理的同步机制、线程间的协调和正确的资源管理,才能有效地避免这些问题,确保程序在多线程环境下的正确性和稳定性。

来看一个经典的案例,这个案例使用了marscode自动生成:

package com.interview.multithreads;

// 定义一个名为ThreadsSafe的公共类,用于多线程安全的示例
public class ThreadsSafe {
    // 声明一个私有变量count,用于存储计数器值,初始值为0
    private int count = 0;

    // 定义一个私有方法increment,用于增加count值
    private void increment() {
        count++;
    }

    // 定义一个公共方法runThreads,用于启动并执行两个线程
    public void runThreads() {
        // 创建第一个线程,使用lambda表达式定义匿名内部类作为线程执行体
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                increment();
            }
        });

        // 创建第二个线程,使用lambda表达式定义匿名内部类作为线程执行体
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                increment();
            }
        });

        // 启动第一个线程
        thread1.start();
        // 启动第二个线程
        thread2.start();

        try {
            // 等待第一个线程结束
            thread1.join();
            // 等待第二个线程结束
            thread2.join();
        } catch (InterruptedException e) {
            // 捕获InterruptedException异常,如果发生异常,打印堆栈跟踪
            e.printStackTrace();
        }

        // 打印计数器的值
        System.out.println("Count: " + count);
    }

    // 定义main方法作为程序入口点
    public static void main(String[] args) {
        // 创建ThreadsSafe对象
        ThreadsSafe threadsSafe = new ThreadsSafe();
        // 调用对象的runThreads方法,启动并执行线程
        threadsSafe.runThreads();
    }
  
}

运行结果之后,打印出的count值应该会小于预期值20000:

image.png

这段代码中产生线程安全问题的原因在于,对共享变量 count 的递增操作 count++ 不是一个原子操作。

在多线程环境中,count++ 实际上可以分解为以下三个步骤:

  1. 获取当前 count 的值。

  2. 将获取的值加 1 。

  3. 将加 1 后的值写回 count 。

当多个线程同时执行这个操作时,可能会出现以下情况:
例如,线程 1 获取了 count 的值为 5 ,还没来得及完成加 1 和写回操作,线程 2 也获取了 count 的值为 5 。然后线程 1 完成操作将 count 写回为 6 ,接着线程 2 也完成操作将 count 写回为 6 ,这样就丢失了一次递增操作,导致最终的结果不正确。

这种情况可能会在不同的执行顺序中反复出现,使得 count 的最终值不可预测,无法达到预期的累加效果,从而产生了线程安全问题。

image.png

当然由于多线程下各线程执行指令的时机无法确定,也有可能出现如下正确的情况:

image.png

2、如何解决线程安全问题?

在 Java 中,常见的解决线程安全问题的方法有以下几种:

  1. 使用 synchronized 关键字

    • 可以用于修饰方法,使得在同一时刻只有一个线程能够执行该方法。

    • 也可以用于修饰代码块,指定一段代码在同一时刻只能被一个线程执行。

    示例:

    public class SynchronizedExample {
        private int count = 0;

        public synchronized void increment() {
            count++;
        }

        public void runThreads() {
            // 线程操作
        }
    }
  1. 使用 Lock 接口

    • 相比 synchronized 更加灵活,可以实现更细粒度的控制。

    • 例如 ReentrantLock 。

    import java.util.concurrent.locks.Lock;
    import java.util.concurrent.locks.ReentrantLock;

    public class LockExample {
        private int count = 0;
        private Lock lock = new ReentrantLock();

        public void increment() {
            lock.lock();
            try {
                count++;
            } finally {
                lock.unlock();
            }
        }
    }
  1. 使用线程安全的类

    • 例如 AtomicInteger 用于整数的原子操作。

    import java.util.concurrent.atomic.AtomicInteger;

    public class AtomicExample {
        private AtomicInteger count = new AtomicInteger(0);

        public void increment() {
            count.incrementAndGet();
        }
    }
  1. 使用线程局部变量(ThreadLocal)

    • 每个线程都有其独立的变量副本,不会相互干扰。
    public class ThreadLocalExample {
        private ThreadLocal<Integer> count = ThreadLocal.withInitial(() -> 0);

        public void increment() {
            count.set(count.get() + 1);
        }
    }
  1. 使用并发容器

    • 例如 ConcurrentHashMap 替代 HashMap 等。
    import java.util.concurrent.ConcurrentHashMap;

    public class ConcurrentContainerExample {
        private ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

        public void operateMap() {
            // 并发操作
        }
    }

在这个案例中,由于count要在多个线程间共享,同时又不涉及到容器,所以可以使用前三种方法改造代码。

  1. 使用 synchronized 关键字
    // 定义一个私有方法increment,用于增加count值
    private synchronized void increment() {
        count++;
    }

2、 使用 Lock 接口

  // 定义一个公共方法runThreads,用于启动并执行两个线程
    public void runThreads() {
        // 创建一个可重入锁
        Lock lock = new ReentrantLock();

        // 创建第一个线程,使用lambda表达式定义匿名内部类作为线程执行体
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    increment();
                } finally {
                    lock.unlock();
                }
            }
        });

        // 创建第二个线程,使用lambda表达式定义匿名内部类作为线程执行体
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    increment();
                } finally {
                    lock.unlock();
                }
            }
        });

        // 启动第一个线程
        thread1.start();
        // 启动第二个线程
        thread2.start();

        try {
            // 等待第一个线程结束
            thread1.join();
            // 等待第二个线程结束
            thread2.join();
        } catch (InterruptedException e) {
            // 捕获InterruptedException异常,如果发生异常,打印堆栈跟踪
            e.printStackTrace();
        }

        // 打印计数器的值
        System.out.println("Count: " + count);
    }
  1. 使用线程安全的类
// 声明一个CAS变量,用于存储计数器值,初始值为0
private AtomicInteger count = new AtomicInteger(0);

// 定义一个私有方法increment,用于增加count值
private void increment() {
    count.incrementAndGet();
}

3、synchronized原理到底是什么?

synchronized本质上是由JVM来实现的同步机制,所以在Java代码中是看不到它的实现的,但是面试官又特别喜欢考察你对JVM底层的理解。

synchronized底层有一种锁膨胀机制,根据当前并发度的不同(抢占锁的线程多还是少),底层实现是不同的。

synchronized锁膨胀机制在 Java 5 中引入。 在 Java 早期版本中,synchronized的实现相对简单和直接。但从 Java 5 开始,对synchronized进行了优化,引入了锁膨胀机制。

例如,在多线程竞争不激烈的情况下,synchronized可能表现为偏向锁或者轻量级锁,以提高性能。只有在多线程竞争激烈时,才会逐渐膨胀为重量级锁。 这种优化机制使得 Java 在并发编程中的性能得到了显著提升。

JVM将不同并发度的锁级别分为:

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁

怎么去记录当前是什么级别的锁呢?

对象头中的标识

比如以这段代码为例

synchronized(obj){
}

obj对象在JVM中保存时,除了存放对象本身之外,还会存放一份对象头,而在对象头中包含一块叫做Mark Word的内容,里面就记录了锁的级别。

image.png

比如上图展示了32位虚拟机的对象头,最后3位区分出了不同的锁状态

  • 无锁 001
  • 偏向锁 101
  • 轻量级锁 00
  • 重量级锁 10

几种锁的详细描述

  • 无锁:在初始时,没有任何线程对其进行锁定操作,这就是无锁的情况。
  • 偏向锁:是一种针对只有一个线程访问同步块时的优化锁。它假定在大多数情况下,同步操作不会有竞争,所以会偏向于第一个获得锁的线程。例如,在一个单线程的程序中,多次访问同一个同步块,就可能使用偏向锁来提高性能。
  • 轻量级锁:当存在少量线程竞争同步资源时使用。它通过 CAS 操作(Compare and Swap,比较并交换)来尝试获取锁,如果获取成功就使用轻量级锁。比如,在一个有两个线程偶尔竞争同一资源的场景中,可能会使用轻量级锁。
  • 重量级锁:当存在大量线程竞争同步资源,或者线程阻塞等待的时间较长时,就会升级为重量级锁。重量级锁会导致线程阻塞和唤醒,开销较大。例如,在一个高并发的环境下,众多线程频繁竞争一个关键资源,就需要使用重量级锁来保证线程安全和数据一致性。 重量级锁会使用到Monitor技术。

简单来说就是初始时无锁状态001,一个线程去抢锁变成101,并记录线程ID,如果还是这个线程去抢(根据线程ID判断),那就停留在偏向锁,如果有其他线程来抢,升级为轻量级锁,每次加锁都要在线程中保存下锁的信息,并且指向这个锁信息。如果大量线程抢锁,就要升级为重量级锁了,因为这种情况下要去单独维护阻塞线程等信息,比较复杂,所以使用了Monitor技术。

什么是重量级锁的Monitor技术?

在JVM中,Monitor其实本质上就是一种复杂的数据结构,每一个加锁的对象会持有一个monitor对象,围绕这个数据结构实现了重量级锁的功能。

数据结构的具体实现如下:

image.png

详细数据结构代码如下:

ObjectMonitor() {
_header;
_count ; // 非常重要,表示锁计数器,_count = 0表示还没人加锁,_count > 0 表示加锁的次数 _waiters;
_recursions; 
_owner; // 非常重要,指向加锁成功的线程,_owner = null 时候表示没人加锁 
_waitset; // wait线程的集合,在synchorized代码块中调用wait()方法的线程会被加入到此集合中沉睡,等待别人叫醒它
_waitsetLock;
_responsiable;
_succ;
_cxq; 
_freenext;
_entrylist; // 非常重要,等待队列,加锁失败的线程会被加入到这个等待队列中,等待再次争抢锁 _spinFreq; // 获取锁之前的自旋的次数 
_spinclock; // 获取之前每次锁自旋的时间
ownerIsThread; 
}
  • owner 指向当前加锁成功的线程,当然只有一个
  • waitset 保存了wait()等待中的线程
  • entrylist也叫entry set,保存了阻塞的线程

如果线程1和线程2去抢锁,线程1抢到了,而线程2没有抢到,就会以如下图所示:

image.png

如果线程1执行完之后,monitor就会从entry set获得一个线程,让其抢到锁:

image.png

什么是自旋锁?

自旋锁(Spin Lock) 是一种用于多线程同步的锁机制。 自旋锁的主要特点是,当一个线程试图获取一个被占用的自旋锁时,它不会进入阻塞状态,而是在原地“自旋”,也就是不停地循环尝试获取锁,直到获取到锁为止。

在重量级锁中,如果一个线程执行代码时间很短,那么其他抢锁线程会频繁处于阻塞-被唤醒的过程中,消耗大量的CPU

例如,在一个实时性要求很高的系统中,如果线程阻塞和唤醒的开销较大,而且预计锁被占用的时间很短,使用自旋锁可以避免线程切换带来的开销。

以下两个参数是由JVM控制的,用来判断要重试多少次以及自旋多少时间,程序员无需过多关注,JVM会帮你搞定

_spinFreq; // 获取锁之前的自旋的次数 
_spinclock; // 获取之前每次锁自旋的时间