Java多线程面试题

目录

一.线程和进程的区别

二.保证线程安全的手段

三.创建多线程的方式

四.线程池的讲解

五.volatile和synchronzied的区别

[六.synchronized 和 Lock的区别](#六.synchronized 和 Lock的区别)

七.产生死锁的条件

八.Java当中常见的锁策略


本专栏全是博主自己收集的面试题,仅可参考,不能相信面试官就出这种题目。

一.线程和进程的区别

线程是轻量级进程,它不会像进程一样,创建的时候需要大量的资源(PCB,硬盘资源),而也是因为不需要大量资源,它的速度很快很快。

为什么不需要创建大量资源呢?因为它是共享的,先创建一个进程,就具备了:

  1. 内存空间: 新进程分配一个地址空间,这个地址空间包含了进程的代码段、数据段和堆栈空间。

  2. 进程控制块(PCB): 每个进程都需要一个 PCB 来存储和管理进程的信息,包括进程状态、程序计数器(PC)、堆栈指针、内存分配信息、文件描述符、进程优先级等。PCB 是操作系统用来管理和调度进程的重要数据结构。

  3. 程序和数据: 新进程需要加载和执行的程序文件,以及程序执行所需的数据。这些通常是通过文件系统中的可执行文件和数据文件来提供的。

  4. 上下文切换: 创建进程时,操作系统需要进行上下文切换,保存当前进程的状态,并将新进程的状态加载到合适的数据结构(如 PCB 和 CPU 寄存器)中,以便新进程能够开始执行。

  5. 文件描述符和 I/O 资源: 如果新进程需要访问文件或其他 I/O 设备,操作系统需要为其分配文件描述符,并确保进程有足够的权限和资源来访问这些设备。

  6. 权限和安全上下文: 创建进程时,操作系统还需要确保新进程在安全上下文中运行,即进程有足够的权限执行其所需的操作,如访问特定的文件或调用特定的系统服务。

而在其中,线程共享的资源是:内存空间、文件描述符、进程上下文和堆内存。

线程的私有资源是:线程栈、寄存器集合、线程ID以及线程状态。

二.保证线程安全的手段

了解线程安全的手段前,我们需要了解一下,为什么线程不安全:多线程环境下,当多个线程同时访问和修改共享资源时,没有采取合适的同步措施,可能导致数据不一致、程序崩溃或不可预测的行为

  1. 竞态条件(Race Condition): 竞态条件发生在多个线程同时访问某个共享资源,并且对该资源的访问顺序决定了最终的执行结果。如果没有正确同步,可能会导致意外的结果或数据损坏。

  2. 数据竞争(Data Race): 数据竞争是指至少两个线程并发访问同一内存位置,并且至少其中一个是写操作。如果没有适当的同步机制(如互斥锁或原子操作),则读取到的数据可能是不一致的或无效的。

  3. 非原子操作: 如果一个操作不是原子性的,即不能保证在执行期间不被中断,并且可能在其执行过程中被其他线程干扰,那么在多线程环境下,可能会导致部分执行的结果对其他线程是可见的,从而破坏了程序的预期行为。

  4. 死锁(Deadlock): 死锁发生在多个线程互相等待对方持有的资源而无法继续执行的情况。如果线程在等待共享资源时不释放已占有的资源,可能会导致整个系统的停顿。

  5. 资源耗尽: 如果线程在使用共享资源时没有适当的释放或管理,可能会导致资源的过度消耗,最终导致系统的崩溃或性能下降。

  6. 信号量错误: 当多个线程同时操作信号量或其他同步原语时,如果没有正确地进行加锁和解锁操作,可能会导致信号量的计数出错或者条件变量的错误使用,从而引发程序异常。

在Java当中,保证线程安全的情况有:

  1. 使用锁机制:锁机制是一种用于控制多个线程对共享资源进行访问的机制。在 Java 中,锁机制主要有两种:synchronized 关键字和 Lock 接口。synchronized 关键字是 Java 中最基本的锁机制,它可以用来修饰方法或代码块,以实现对共享资源的互斥访问。而 Lock 接口是 Java5 中新增的一种锁机制,它提供了比 synchronized 更强大、更灵活的锁定机制,例如可重入锁、读写锁等;
  2. 使用线程安全的容器:如 ConcurrentHashMap、Hashtable、Vector。需要注意的是,线程安全的容器底层通常也是使用锁机制实现的;
  3. 使用本地变量:线程本地变量是一种特殊的变量,它只能被同一个线程访问。在 Java 中,线程本地变量可以通过 ThreadLocal 类来实现。每个 ThreadLocal 对象都可以存储一个线程本地变量,而且每个线程都有自己的一份线程本地变量副本,因此不同的线程之间互不干扰。

三.创建多线程的方式

在Java当中,创建线程有五种方式:

  1. 继承Thread类

    1. Java 中所有的线程都是通过继承 Thread 类来实现的。要创建一个线程,你可以创建一个新的类,继承自 Thread,并重写 run() 方法来定义线程的主体逻辑。
    java 复制代码
    public class MyThread extends Thread {
        public void run() {
            // 线程的主体逻辑
            System.out.println("Thread running");
        }
    
        public static void main(String[] args) {
            MyThread thread = new MyThread();
            thread.start(); // 启动线程
        }
    }
  2. 实现Runnalbe接口

    1. 另一种创建线程的方式是实现 Runnable 接口,并将其作为参数传递给 Thread 类的构造方法。
    java 复制代码
    public class MyRunnable implements Runnable {
        public void run() {
            // 线程的主体逻辑
            System.out.println("Runnable running");
        }
    
        public static void main(String[] args) {
            MyRunnable myRunnable = new MyRunnable();
            Thread thread = new Thread(myRunnable);
            thread.start(); // 启动线程
        }
    }
  3. 使用匿名内部类

    1. 如果线程的逻辑比较简单,可以使用匿名内部类来创建和启动线程。
    java 复制代码
    public class ThreadExample {
        public static void main(String[] args) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    // 线程的主体逻辑
                    System.out.println("Anonymous thread running");
                }
            });
            thread.start(); // 启动线程
        }
    }
  4. 使用Callable 和 Future

    1. Callable 接口允许线程返回结果,并且可以通过 Future 来获取结果。
    java 复制代码
    import java.util.concurrent.Callable;
    import java.util.concurrent.FutureTask;
    
    public class CallableExample {
        public static void main(String[] args) throws Exception {
            Callable<Integer> callable = new Callable<Integer>() {
                public Integer call() {
                    // 线程的主体逻辑
                    return 123;
                }
            };
    
            FutureTask<Integer> futureTask = new FutureTask<>(callable);
            Thread thread = new Thread(futureTask);
            thread.start();
    
            int result = futureTask.get(); // 获取线程返回的结果
            System.out.println("Result: " + result);
        }
    }
  5. 使用线程池

    1. 在实际开发中,通常推荐使用线程池来管理和复用线程,而不是直接创建和启动线程。Java 提供了 ExecutorServiceThreadPoolExecutor 等实现线程池的类。
    java 复制代码
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    
    public class ThreadPoolExample {
        public static void main(String[] args) {
            ExecutorService executor = Executors.newFixedThreadPool(10);
    
            for (int i = 0; i < 10; i++) {
                executor.submit(() -> {
                    // 线程的主体逻辑
                    System.out.println("Thread running in pool");
                });
            }
    
            executor.shutdown(); // 关闭线程池
        }
    }

以上是几种常见的创建线程的方式。在选择使用哪种方式时,需要根据具体的需求和场景来决定,例如是否需要线程返回结果、是否需要管理大量线程等。

四.线程池的讲解

线程池是在并发编程中非常重要的概念,它能有效地管理和复用线程,提高程序的性能和资源利用率。在 Java 中,线程池由 ExecutorService 接口和其实现类 ThreadPoolExecutor 来实现。Spring 项目中,会使用代码可读性更高的 ThreadPoolTaskExecutor 来创建线程池

为什么使用线程池呢?

  1. 降低资源消耗:通过复用线程,避免线程创建和销毁的开销,减少了系统的资源消耗。

  2. 提高响应速度:当有任务到达时,可以立即执行,而不必等待新线程创建。

  3. 提高线程的可管理性:可以限制线程数量,防止因为过多线程导致系统资源耗尽或性能下降。

java 复制代码
public static void main(String[] args) {
    // 任务的具体方法
    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("当前任务被执行,执行时间:" + new Date() +
                               " 执行线程:" + Thread.currentThread().getName());
            try {
                // 等待 1s
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    };
    // 创建线程,线程的任务队列的长度为 1
    ThreadPoolExecutor threadPool = new ThreadPoolExecutor(1, 1,
                                                           100, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1),
                                                           new ThreadPoolExecutor.DiscardPolicy());
    // 添加并执行 4 个任务
    threadPool.execute(runnable);
    threadPool.execute(runnable);
    threadPool.execute(runnable);
    threadPool.execute(runnable);
    // 线程池执行完任务,关闭线程池
    threadPool.shutdown();
}
java 复制代码
import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 核心线程数为 3,最大线程数为 5,空闲线程存活时间为 10 秒
        // 使用 ArrayBlockingQueue 作为工作队列,容量为 10
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                3,  // corePoolSize
                5,  // maximumPoolSize
                10, // keepAliveTime
                TimeUnit.SECONDS, // 时间单位
                new ArrayBlockingQueue<>(10) // 工作队列
        );

        // 设置线程工厂,用于创建线程时自定义线程属性
        executor.setThreadFactory(r -> {
            Thread thread = new Thread(r);
            thread.setName("CustomThread-" + thread.getId());
            return thread;
        });

        // 设置拒绝策略为默认的 AbortPolicy
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());

        // 提交任务给线程池执行
        for (int i = 0; i < 10; i++) {
            final int taskNumber = i;
            executor.execute(() -> {
                System.out.println("Task " + taskNumber + " executed by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000); // 模拟任务执行
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

内部的参数:

  1. 核心线程数(corePoolSize)

    • 定义了线程池中保持的最小线程数,即使它们是空闲的。如果设置了核心线程数,即使线程池中没有任务要执行,也会保持这些核心线程的数量。
  2. 最大线程数(maximumPoolSize)

    • 定义了线程池中允许的最大线程数。当工作队列满了,并且有新的任务提交到线程池时,线程池可以创建更多的线程,直到达到最大线程数。超过最大线程数的任务会被拒绝执行(根据线程池的拒绝策略)。
  3. 空闲线程存活时间(keepAliveTime)

    • 当线程池中的线程数量超过核心线程数时,这些多余的空闲线程在被终止之前等待新任务的最长时间。超过这个时间后,多余的空闲线程会被销毁,直到线程池的大小缩减到核心线程数为止。
  4. 工作队列(workQueue)

    • 用于保存等待执行的任务的队列。不同的线程池实现可以使用不同类型的队列,如 LinkedBlockingQueueArrayBlockingQueueSynchronousQueue 等。这些队列控制着待执行任务的排队机制。
  5. 线程工厂(threadFactory)

    • 用于创建新线程的工厂。可以自定义线程的名称、优先级等属性。
  6. 拒绝策略(RejectedExecutionHandler)

    • 定义了当任务无法被接受执行时的策略。例如,可以选择直接抛出异常、丢弃任务、丢弃队列头部的任务或者由调用线程执行该任务等。

扩展资料:拒绝策略:

  • AbortPolicy:中止策略,线程池会抛出异常并中止执行此任务;
  • CallerRunsPolicy:把任务交给添加此任务的(main)线程来执行;
  • DiscardPolicy:忽略此任务,忽略最新的一个任务;
  • DiscardOldestPolicy:忽略最早的任务,最先加入队列的任务。

线程池的关闭:

三步:先调用shutdown()停止线程池进行接收新任务,使用awaitTermination() 方法来等待所有任务完成执行,使用shutdownNow()关闭运行的线程。

java 复制代码
ExecutorService executor = Executors.newFixedThreadPool(10);
// 提交任务到线程池
for (int i = 0; i < 100; i++) {
    executor.submit(new MyTask());
}
// 关闭线程池
executor.shutdown();
try {
    // 等待所有任务完成执行
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        // 如果等待超时,强制关闭线程池
        executor.shutdownNow();
    }
} catch (InterruptedException e) {
    // 处理异常
}

五.volatile和synchronzied的区别

volatilesynchronized 是 Java 中用于处理多线程并发访问的关键字。

**volatile:**一种轻量级的同步机制,可保证变量的可见性,一个线程修改之后,其他线程立即看见修改的值。

**synchronized:**重量级同步机制,提供了原子性和互斥性,同一时刻只有一个线程可以访问被synchronzied修饰的方法或者代码块。

volatile

  • 适用于单个变量的读写操作,例如标志位的更新,状态的判断等。
  • 不适合复合操作的原子性保证,如递增操作(i++),因为 volatile 不能保证原子性。

正常情况下,如果没有修饰常量,那么一个线程修改之后,另一个线程依然还是用最初始的值。

java 复制代码
public class VolatileExample {
    private volatile boolean flag = false;

    public void toggleFlag() {
        flag = !flag; // 状态切换操作
    }

    public boolean isFlagSet() {
        return flag; // 状态读取操作
    }

    public static void main(String[] args) {
        VolatileExample example = new VolatileExample();

        // 线程1:修改flag
        new Thread(() -> {
            example.toggleFlag();
            System.out.println("Thread 1 toggled flag");
        }).start();

        // 线程2:读取flag
        new Thread(() -> {
            while (!example.isFlagSet()) {
                // 等待flag变为true
            }
            System.out.println("Thread 2 detected flag is true");
        }).start();
    }
}

synchronzied

  • 适用于复合操作的原子性保证,比如一个方法内涉及多个字段的读写,或者需要保证读写操作的一致性。
  • 可以用于代码块或方法级别的同步,能够确保线程安全,但过多的使用可能导致性能问题。
java 复制代码
public class SynchronizedExample {
    private Object lock = new Object();
    private int count = 0;

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

    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

二者区别:

  • 粒度

    • volatile 是用来修饰单个变量,保证变量的可见性。
    • synchronized 可以修饰代码块或方法,提供原子性和互斥性的操作。
  • 性能

    • volatilesynchronized 更轻量级,因为它不涉及线程的阻塞和唤醒。
    • synchronized 在涉及竞争时会引入线程的阻塞和唤醒,可能会影响性能。
  • 适用场景

    • 如果只需要保证变量的可见性,使用 volatile 是比较合适的选择。
    • 如果需要保证复合操作的原子性和线程安全,使用 synchronized 更为适当。

六.synchronized 和 Lock的区别

Java 中用于实现线程同步的机制,它们都可以保证线程安全。

synchronized

synchronized 可用来修饰普通方法、静态方法和代码块,当一个线程访问一个被 synchronized 修饰的方法或者代码块时,会自动获取该对象的锁,其他线程将会被阻塞,直到该线程执行完毕并释放锁。

Lock

Lock 是一种线程同步的机制,它与 synchronized 相似,可以用于控制对共享资源的访问。相比于 synchronized,Lock 的特点在于更加灵活,支持更多的操作。 定义了更多的方法:

  • lock():获取锁,如果锁已被其他线程占用,则阻塞当前线程。
  • tryLock():尝试获取锁,如果锁已被其他线程占用,则返回 false,否则返回 true。
  • tryLock(long timeout, TimeUnit unit):尝试获取锁,在指定的时间范围内获取到锁则返回 true,否则返回 false。
  • unlock():释放锁。

synchronized 和 Lock 主要的区别有以下几个方面:

  1. 锁的获取方式:synchronized 是隐式获取锁的,即在进入 synchronized 代码块或方法时自动获取锁,退出时自动释放锁;而 Lock 需要程序显式地获取锁和释放锁,即需要调用 lock() 方法获取锁,调用 unlock() 方法释放锁。
  2. 锁的性质:synchronized 是可重入的互斥锁,即同一个线程可以多次获得同一把锁,而且锁的释放也只能由获得锁的线程来释放;Lock 可以是可重入的互斥锁,也可以是非可重入的互斥锁,还可以是读写锁。
  3. 锁的粒度:synchronized 是以代码块和方法为单位进行加锁和解锁,而 Lock 可以精确地控制锁的范围,可以支持多个条件变量。
  4. 性能:在低并发的情况下,synchronized 的性能优于 Lock,因为 Lock 需要显式地获取和释放锁,而 synchronized 是在 JVM 层面实现的;在高并发的情况下,Lock 的性能可能优于 synchronized,因为 Lock 可以更好地支持高并发和读写分离的场景。

总的来说,synchronized 的使用更加简单,但是在某些场景下会受到性能的限制;而 Lock 则更加灵活,可以更精确地控制锁的范围和条件变量,但是使用起来比较繁琐。需要根据具体的业务场景和性能需求来选择使用哪种锁机制。

七.产生死锁的条件

死锁(Dead Lock)指的是两个或两个以上的运算单元(进程、线程或协程),互相持有对方所需的资源,导致它们都无法向前推进,从而导致永久阻塞的问题就是死锁。

四大条件:

  • 互斥条件:指运算单元(进程、线程或协程)对所分配到的资源具有排它性,也就是说在一段时间内某个锁资源只能被一个运算单元所占用。
  • 请求和保持条件:指运算单元已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它运算单元占有,此时请求运算单元阻塞,但又对自己已获得的其它资源保持不放。
  • 不可剥夺条件:指运算单元已获得的资源,在未使用完之前,不能被剥夺。
  • 环路等待条件:指在发生死锁时,必然存在运算单元和资源的环形链,即运算单元正在等待另一个运算单元占用的资源,而对方又在等待自己占用的资源,从而造成环路等待的情况。

解决方案:破坏其中一个条件即可。

  1. 按照顺序加锁:尝试让所有线程按照同一顺序获取锁,从而避免死锁。
  2. 设置获取锁的超时时间:尝试获取锁的线程在规定时间内没有获取到锁,就放弃获取锁,避免因为长时间等待锁而引起的死锁。

八.Java当中常见的锁策略

锁策略,用于对锁进行分类和指导锁的(具体)实现

1.乐观锁与悲观锁

乐观锁:假设并发冲突的概率很低,因此不对共享资源加任何锁,而是直接进行操作。在更新数据时,会先比较数据版本(或者其他标识),如果版本匹配,则执行更新操作;如果版本不匹配,则认为数据已被其他线程修改,需要进行冲突处理(例如重试或者放弃操作)。实现方式:CAS

悲观锁:核心思想是假设最坏的情况,即并发情况下一定会发生冲突,因此在访问数据前先获取锁。这种锁会阻塞其他试图获取相同资源的线程,直到当前线程释放锁。

2.读写锁

允许多个线程同时读取共享资源,但是在写入时需要独占资源。这种机制在读操作远远多于写操作的场景中尤为有效,可以提高系统的并发性能。实现方式:

  1. 基于互斥量的实现

    • 使用两个互斥量(mutex),一个用于读操作的互斥量,一个用于写操作的互斥量。
    • 当有线程要进行写操作时,必须先获取写互斥量,同时阻止其他线程获取读互斥量;而读操作则只需要获取读互斥量,不阻塞其他读操作。
  2. 基于信号量的实现

    • 使用两个信号量(semaphore),一个用于控制读者数量,一个用于控制写者。
    • 读者获取读信号量时,如果没有写者正在进行写操作,则允许进入;写者获取写信号量时,需要等待所有读者退出,确保独占写权限。
  3. 基于原子操作的实现

    • 在一些高级语言中(如Java),可以使用原子操作来实现读写锁。通过原子操作可以实现非阻塞的并发控制,效率较高。

3.互斥锁

互斥锁是最基本的锁类型,用于确保在同一时刻只有一个线程可以访问共享资源。一旦一个线程获得了互斥锁,其他试图获取该锁的线程将被阻塞,直到持有锁的线程释放它。在 Java 中,可以使用 ReentrantLock 类实现互斥锁,也可以使用 synchronized 关键字实现隐式的互斥锁。

4.自旋锁

一种基于忙等待的锁,线程在获取锁时不会立即阻塞,而是循环检测锁的状态,直到获取到锁为止。适用于锁保护时间非常短暂的情况。可以使用 AtomicBoolean 或者 AtomicInteger 结合 compareAndSet 方法来实现简单的自旋锁。

5.重入锁

可以被同一个线程多次获取的锁,线程每次获取锁时计数器加一,每次释放锁时计数器减一,直到计数器为零才完全释放锁。相比于 synchronized 关键字,重入锁提供了更灵活的锁获取与释放方式,例如可以设置超时时间、中断响应等。

6.公平锁与非公平锁

线程按照请求锁的顺序依次获取锁,而非公平锁则允许线程插队获取锁,可能会导致某些线程长时间等待。ReentrantLock 可以通过构造函数指定是否使用公平策略。

相关推荐
无情白7 分钟前
k8s运维面试总结(持续更新)
运维·面试·kubernetes
Mryan200514 分钟前
解决GraalVM Native Maven Plugin错误:JAVA_HOME未指向GraalVM Distribution
java·开发语言·spring boot·maven
Naomi52115 分钟前
自定义汇编语言(Custom Assembly Language) 和 Unix & Git
服务器·开发语言·git·unix
烂蜻蜓22 分钟前
C 语言命令行参数:让程序交互更灵活
c语言·开发语言·交互
VX_CXsjNo125 分钟前
免费送源码:Java+SSM+Android Studio 基于Android Studio游戏搜索app的设计与实现 计算机毕业设计原创定制
java·spring boot·spring·游戏·eclipse·android studio·android-studio
zm-v-1593043398626 分钟前
解锁 DeepSeek 与 Matlab:攻克科研难题的技术利刃
开发语言·matlab·信息可视化
ylfhpy30 分钟前
Java面试黄金宝典33
java·开发语言·数据结构·面试·职场和发展·排序算法
照书抄代码34 分钟前
C++11可变参数模板单例模式
开发语言·c++·单例模式·c++11
No0d1es39 分钟前
CCF GESP C++编程 四级认证真题 2025年3月
开发语言·c++·青少年编程·gesp·ccf·四级·202503
乘风!1 小时前
Java导出excel,表格插入pdf附件,以及实现过程中遇见的坑
java·pdf·excel