Java 面试题 - 多线程并发篇

发布时间:2024年01月13日

线程基础

创建线程有几种方式

继承Thread类

可以创建一个继承自Thread类的子类,并重写其run()方法来定义线程的行为。然后可以通过创建该子类的实例来启动线程。

示例代码:

class MyThread extends Thread {
    public void run() {
        // 定义线程的行为
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 启动线程
    }
}

实现Runnable接口

可以创建一个实现了Runnable接口的类,并实现其run()方法。然后可以通过创建该类的实例,并将其作为参数传递给Thread类的构造函数来创建线程。

示例代码:

class MyRunnable implements Runnable {
    public void run() {
        // 定义线程的行为
    }
}

public class Main {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread thread = new Thread(runnable);
        thread.start(); // 启动线程
    }
}

实现Callable接口

Callable接口类似于Runnable接口,但是它可以返回执行结果,并且可以抛出异常。使用Callable需要通过ExecutorService的submit()方法来提交任务,并返回一个Future对象,可以通过该对象获取任务的执行结果。

示例代码:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;

class MyCallable implements Callable<Integer> {
    public Integer call() throws Exception {
        // 定义线程的行为
        return 42;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        MyCallable callable = new MyCallable();
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start(); // 启动线程

        Integer result = futureTask.get(); // 获取执行结果
        System.out.println("结果:" + result);
    }
}

使用匿名类

可以直接使用匿名类来创建线程,可以是继承Thread类或实现Runnable接口的匿名类。

示例代码:

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread() {
            public void run() {
                // 定义线程的行为
            }
        };
        thread.start(); // 启动线程
    }
}

使用线程池

Java提供了Executor框架来管理线程池,通过线程池可以更好地管理和复用线程资源。可以通过Executors类提供的静态方法创建不同类型的线程池,然后提交任务给线程池执行。

示例代码:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class MyTask implements Runnable {
    public void run() {
        // 定义线程的行为
    }
}

public class Main {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5); // 创建固定大小的线程池

        for (int i = 0; i < 10; i++) {
            Runnable task = new MyTask();
            executor.submit(task); // 提交任务给线程池执行
        }

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

runnable 和 callable 的区别

RunnableCallable 接口都用于表示可以在新线程中执行的任务,但它们之间有一些区别:

  • 返回值类型:

    • Runnablerun() 方法没有返回值,因此线程执行完任务后不会有返回结果。
    • Callablecall() 方法可以有返回值,它使用泛型来指定返回类型,可以在执行完任务后返回计算结果。Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。
  • 异常抛出:

    • Runnablerun() 方法不能抛出已检查异常,只能捕获处理未检查异常(RuntimeException)。
    • Callablecall() 方法可以抛出异常,它允许抛出任何类型的异常,包括已检查异常。
  • 多线程执行:

    • Runnable 通过将其实例作为参数传递给 Thread 对象,在新线程中执行。
    • Callable 通常与 ExecutorService 结合使用,通过 submit(Callable) 方法提交任务并异步执行,在获得返回结果时可以通过 Future 对象获取。

总的来说,Runnable 是较为简单的表示任务的接口,适合不需要返回结果或处理异常的任务;而 Callable 则更灵活,可以返回结果和处理异常,适合需要获取任务执行结果和可能抛出异常的情况。

线程的状态转换

线程的状态可以参考JDK中的Thread类中的枚举State,分为:NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED。

状态之间的变化如下图:

在这里插入图片描述

  • 新建
    • 当一个线程对象被创建,但还未调用 start 方法时处于新建状态。
    • 此时未与操作系统底层线程关联
  • 可运行
    • 调用了 start 方法,就会由新建进入可运行
    • 此时与底层线程关联,由操作系统调度执行
  • 终结
    • 线程内代码已经执行完毕,由可运行进入终结
    • 此时会取消与底层线程关联
  • 阻塞
    • 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用cpu 时间
    • 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
  • 等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
  • 有时限等待
    • 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu时间
    • 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
    • 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
    • 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态

start 和 run 的区别

在Java中,线程的启动有两种方式,一种是调用线程对象的start()方法,另一种是直接调用线程对象的run()方法。它们的区别如下:

  • start()方法:

    • 调用start()方法会在新线程中执行run()方法中的代码,即以多线程的方式执行。
    • start()方法会启动一个新的线程,由JVM来调用该线程的run()方法。
    • 在新线程中并发执行,并且不会阻塞当前线程的执行。
    • 如果一个线程已经启动,再次调用start()方法会抛出IllegalThreadStateException异常(start方法只能被调用一次)。
  • run()方法:

    • 直接调用run()方法会在当前线程中执行run()方法中的代码,即以单线程的方式执行。
    • run()方法就是普通的方法调用,不会启动新线程,而是在当前线程中同步执行run()方法中的代码。
    • 调用该方法会阻塞当前线程,直到run()方法执行完毕。

因此,start()方法是启动一个新的线程并执行其中的run()方法,而run()方法是在当前线程中执行run()方法的代码,没有并发执行的效果。通常情况下,我们应该使用start()方法来启动线程,以实现多线程并发执行的效果。

线程同步以及线程调度相关的方法

线程同步:

  • synchronized关键字:使用synchronized关键字修饰方法或代码块,可以确保多个线程对共享资源的访问顺序执行,从而避免线程之间的竞争条件和数据不一致性。
  • wait()、notify()和notifyAll()方法:这三个方法通常与synchronized关键字一起使用,wait()方法能让线程等待,notify()和notifyAll()方法能够唤醒等待的线程。

线程调度:

  • sleep()方法:让当前线程暂停执行一段时间,并让出CPU的执行权。
  • yield()方法:让当前线程让出CPU的执行权,让线程调度器重新选择其他线程来执行。
  • join()方法:让一个线程等待另一个线程执行完成之后再继续执行。

notify() 和 notifyAll() 的区别

  • notify()方法用于唤醒处于等待状态的单个线程,该线程是由对象的monitor所保护的。当调用notify()方法时,系统会在等待该对象monitor的线程中选择一个线程进行唤醒,但具体唤醒哪个线程是无法确定的。

  • notifyAll()方法用于唤醒所有处于等待状态的线程,这些线程是由对象的monitor所保护的。调用notifyAll()方法会唤醒所有等待该对象monitor的线程,让它们都有机会争取获取对象锁。

因此,notify()和notifyAll()的主要区别在于,notify()只能唤醒单个线程,而notifyAll()可以唤醒所有等待线程。在使用notify()时需要确保只有一个线程真正需要被唤醒,而在使用notifyAll()时则更适合一次性唤醒所有等待线程的情况。

wait 和 sleep 的区别

共同点

  • wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态

不同点

  • 方法归属不同
    • sleep(long) 是 Thread 的静态方法
    • 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
  • 醒来时机不同
    • 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
    • wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
    • 它们都可以被打断唤醒
  • 锁特性不同(重点)
    • wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
    • wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃cpu,但你们还可以用)
    • 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)

如何保证线程按顺序执行

当需要确保多个线程按照特定顺序执行时,可以使用join()方法来控制线程的执行顺序。例如,假设有三个线程t1、t2和t3,需要按照t3调用t2,t2调用t1的顺序执行。可以如下实现:

public class JoinExample {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            System.out.println("t1");
        });

        Thread t2 = new Thread(() -> {
            try {
                t1.join(); // 等待t1执行完毕
                System.out.println("t2");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                t2.join(); // 等待t2执行完毕
                System.out.println("t3");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        // 启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}

线程t3调用t2的join(),确保t2会在t3执行前完成;线程t2调用t1的join(),确保t1会在t2执行前完成。这样,就能保证线程按照特定的顺序执行。

线程池

为什么使用线程池,优势是什么

使用线程池的优势主要体现在以下几个方面:

  1. 节约系统资源:线程的创建和销毁是资源密集型的操作,而线程池可以重用已经存在的线程,避免重复创建和销毁,从而节约系统资源消耗。

  2. 提高响应速度:线程池可以使任务在有空闲线程时就立即执行,而不需要等待线程创建完成,从而提高任务的响应速度。

  3. 控制并发数量:线程池可以控制最大并发执行的线程数量,避免因线程数过多导致系统负载过重,从而提高系统的稳定性和性能。

  4. 增强可管理性:线程池统一管理线程的创建、销毁和调度,能够更好地对线程进行分配、调优和监控,从而提高线程的可管理性。

综合来看,使用线程池可以有效地降低系统资源消耗,提高任务响应速度,控制并发数量,并增强线程的可管理性。

线程池工作原理

  1. 提交任务到线程池;
  2. 线程池判断核心线程是否在执行任务,若有空闲或尚未创建核心线程,则创建新的核心线程来执行任务;
  3. 若核心线程都在执行任务,线程池判断工作队列是否已满,若未满则将任务存储在工作队列中;
  4. 若工作队列已满,线程池判断线程数是否小于最大线程数,若是则创建临时线程直接执行任务,执行完任务后检查阻塞队列中是否有等待的线程,如有则使用非核心线程执行阻塞队列中的任务;
  5. 若线程数大于最大线程数,则触发拒绝策略进行处理。

在这里插入图片描述

线程池的种类

Excutors 可以创建线程池的常见4 种方式:

  • newFixedThreadPool(固定大小线程池):该线程池维护固定数量的线程,适用于需要控制并发线程数的场景。线程池的大小在创建时指定,任务提交后,线程池会立即执行任务。
  • newCachedThreadPool(缓存线程池):该线程池根据任务数量的变化自动调整线程池的大小。线程池的线程数会根据任务的数量动态增长或缩减。适用于执行大量的短期任务的场景。
  • newSingleThreadExecutor(单线程线程池):该线程池只有一个线程在工作,所有任务按顺序执行。适用于需要保证任务按照顺序执行的场景。
  • newScheduledThreadPool(定时任务线程池):该线程池用于执行定时任务和周期性任务。可以指定线程池的核心线程数,可以按固定的频率执行任务。

线程池的核心参数

  • corePoolSize:线程池中核心线程的数量(也称为线程池的基本大小)。当提交一个任务时, 线程池会新建一个线程来执行任务,直到当前线程数等于corePoolSize。如果调用了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有基本线程。
  • maximumPoolSize:线程池中允许的最大线程数。线程池的阻塞队列满了之后,如果还有任务提 交,如果当前的线程数小于maximumPoolSize,则会新建线程来执行任务。注 意,如果使用的是无界队列,该参数也就没有什么效果了。
  • keepAliveTime 和 unit:当线程池中的线程数大于核心线程数时,空闲线程的存活时间。空闲线程在超过 keepAliveTime 时间后会被终止,但只会终止多余核心线程数的线程。这里提到了keepAliveTime的单位为TimeUnit,用于指定时间的单位。
  • workQueue:用来保存等待执行的任务的BlockQueue阻塞队列,等待的任务必须实现Runnable 接口。选择如下:

ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。

LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。

PriorityBlockingQueue:具有优先级别的阻塞队列。

SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

  • threadFactory:用于设置创建线程的工厂。ThreadFactory的作用就是提供创建线程的功能的线 程工厂。他是通过newThread()方法提供创建线程的功能,newThread()方法创建 的线程都是“非守护线程”而且“线程优先级都是默认优先级”。

  • handler RejectedExecutionHandler,线程池的拒绝策略。所谓拒绝策略,是指将任务添加到线程池中时,线程池拒绝该任务所采取的相应策略。当向线程池中提交任务时,如果此时线程池中的线程已经饱和了,而且阻塞队列也已经满了,则线程池会选择一种拒绝策略来处理该任务。

    线程池提供了四种拒绝策略:

AbortPolicy: 直接抛出异常,阻止系统正常运行。

CallerRunsPolicy: 只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。

DiscardOldestPolicy: 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。

DiscardPolicy: 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案

为什么不建议用Executors创建线程池

虽然使用Executors创建线程池非常方便,但也确实存在一些不建议使用的原因。以下是一些主要的原因:

  • 固定大小的线程池:Executors提供了一种固定大小的线程池,使用newFixedThreadPool方法可以创建该类型的线程池。然而,固定大小的线程池可能导致资源的浪费,尤其是在不需要这么多线程来处理任务的情况下。

  • 单线程的线程池:同样,Executors也提供了newSingleThreadExecutor方法来创建一个单线程的线程池。但是,如果这个线程在工作过程中发生异常而终止,那么会创建一个新的线程来替代它,这可能会导致问题难以追踪。

  • 无界队列:Executors提供的线程池通常使用无界的任务队列(如LinkedBlockingQueue),如果任务的提交速度大于任务处理的速度,就会导致队列中积压大量的任务,最终可能耗尽系统资源。

  • 默认的拒绝策略:Executors创建的线程池通常采用默认的拒绝策略(抛出异常),这可能会导致任务丢失或者系统不稳定。

因此,我们建议在实际开发中,应该根据具体的需求来手动创建ThreadPoolExecutor,以便更好地控制线程池的大小、队列的大小、拒绝策略以及线程工厂等参数。这样可以根据实际情况来优化线程池的性能,以及更好地处理任务。

线程池中会用到哪些队列

在线程池中,常用的队列包括:

  • ArrayBlockingQueue:基于数组实现的有界阻塞队列。在使用线程池时,可以通过指定队列的大小来控制线程池的最大承载能力,防止资源耗尽的问题。

  • LinkedBlockingQueue:基于链表实现的无界阻塞队列。由于其容量理论上可以无限增长,因此在使用此队列时需要特别注意,当任务提交速度过快,可能会导致队列无限增长,最终消耗完系统资源。

  • PriorityBlockingQueue:基于最小二叉堆实现的优先级队列,属于无界阻塞队列。该队列会根据元素的优先级进行排序,但同样也存在无限增长的潜在问题。

  • DelayQueue:该队列用于延迟任务的执行,只有当指定的延迟时间到了,才能从队列中获取到该元素。这也是一个无界队列,需要小心使用以避免资源耗尽问题。

  • SynchronousQueue:不存储元素的阻塞队列,在队列中放入一个元素后必须等待另一个线程取出该元素,因此实际上不会存储元素,而是在传递元素。在某些情况下可以使用此队列实现线程间的同步。

总的来说,虽然无界队列可以在某些情况下提供灵活性,但为了防止资源耗尽等问题的发生,通常建议在使用线程池时选择有界队列,以限制线程池的最大承载能力。

submit 和 execute 方法的区别

你提到的这些区别都很准确:

  • 参数类型不同:execute方法只能接受Runnable对象,而submit方法既可以接受Runnable对象,也可以接受Callable对象。

  • 返回值不同:submit方法会返回一个Future对象,通过这个Future对象可以获取到任务执行的结果或者等待任务执行完成。execute方法没有返回值,因此无法获得任务执行的结果。

  • 异常处理:通过使用submit方法,可以方便地控制和处理任务的异常。通过Future的get方法可以捕获线程中的异常。而execute方法在主线程无法捕获任务执行过程中的异常。

总的来说,如果需要获取任务执行的结果,或者需要控制任务的异常,使用submit方法更为合适。而如果不需要知道任务执行的结果,也不需要捕获任务执行过程中的异常,那就可以使用execute方法。

如何确定核心线程数

确定核心线程数时需要考虑业务类型以及系统的CPU和IO情况。以下是一些常见的指导原则:

  • CPU 密集型任务:

    • 对于单个 CPU 核心能力被充分利用的任务(例如复杂的数学运算),可以考虑设置核心线程数为 CPU 核心数的数量,以避免线程切换开销。在多核处理器上,可以设置为 CPU 核心数+1,以确保所有 CPU 核心都能得到充分利用。
  • IO 密集型任务:

    • 对于需要大量 IO 操作的任务(例如文件读写、网络请求),IO 阻塞耗时可能会导致 CPU 闲置。此时可以适当增加线程数以利用 CPU 闲置时间,一般会设置为 2*CPU 核心数,以确保在某些线程被阻塞的情况下仍能充分利用 CPU。
  • 综合考虑:

    • 对于同时存在 CPU 密集型和 IO 密集型任务的系统,可能需要针对不同类型的任务分别设置线程池,或者动态调整线程池参数以适应不同的任务类型。

在实际应用中,最佳的线程池配置可能需要根据具体的业务场景和系统负载进行调整和优化。可以通过监控系统的运行情况和性能指标,不断调整线程池参数来达到最优的配置。

线程中并发锁

什么是线程死锁

线程死锁是指多个线程因竞争资源或相互等待对方释放资源而陷入的一种阻塞状态。具体来说,线程死锁通常发生在多个线程同时持有该资源,但又互相需要对方所持有的资源的情况下。这种情况下,每个线程都在等待对方释放资源,导致所有线程都无法继续执行,从而形成了死锁状态。

举例来说,线程A持有资源X并等待资源Y,而线程B持有资源Y并等待资源X。此时如果没有外部干预来打破这种相互等待的情况,那么线程A和线程B都无法继续执行,形成了死锁。

线程死锁是多线程编程中常见的问题,解决方法包括合理设计资源获取顺序、使用超时等待机制、以及避免持有过多的资源等。要避免和解决线程死锁问题需要深入理解并合理管理多线程中的资源竞争和同步问题。

形成死锁的四个必要条件是什么

  • 互斥条件:至少有一个资源必须处于非共享模式,即一段时间内某资源只由一个进程占用。
  • 占有且等待条件:进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占用,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。
  • 不可抢占条件:已经分配的资源不能被强行抢占,只能由进程自己释放。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系,比如进程集合A在等B,B在等C,C在等A。

如何避免线程死锁

  • 避免循环等待条件:设计时避免形成头尾相接的资源循环等待关系,可以按照特定顺序请求资源,避免交叉依赖。

  • 资源分配的统一性:尽量使用统一的资源分配方法,避免任务对资源的获取顺序做出假设,降低死锁的可能性。

  • 引入超时机制:在获取资源时引入超时等待,如果等待超时则放弃当前资源请求,避免长时间等待。

  • 死锁检测与恢复:定期检测系统中是否存在死锁,如果检测到死锁,可以采取一定的措施打破死锁,比如中断某些进程或回滚操作。

  • 合理的资源释放:及时释放不再需要的资源,避免长时间占有资源。

总之,避免线程死锁需要在系统设计和实现中综合考虑多种因素,包括资源分配策略、等待超时机制等。

Synchronized 的实现原理 和 作用范围

Synchronized的实现原理是基于对象的锁机制,当一个线程进入一个被synchronized修饰的代码块或方法时,它会尝试获取这个对象的锁。如果该对象的锁已经被其他线程获取,那么这个线程就会被阻塞,直到锁被释放。一旦获取了对象的锁,线程就可以执行代码块或方法,执行完后释放锁,其他线程才能再次获取锁。

Synchronized的作用范围可以是代码块或方法,也可以是对象或类。在代码块或方法上加synchronized关键字时,意味着对当前对象的同步,只有持有该对象锁的线程才能执行该代码块或方法。而在对象或类上加synchronized关键字时,意味着对该对象或类的所有实例或静态成员的同步,只有持有该对象或类锁的线程才能执行相关代码块或方法。

在下面代码中,increment方法被synchronized修饰,意味着只有一个线程能够同时调用这个方法,并且会保证对count的操作是线程安全的。

public class SynchronizedExample {
    private int count = 0;

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

CAS

CAS是Compare And Swap(比较再交换)的缩写。它是一种现代CPU广泛支持的指令,用于对内存中的共享数据进行操作。CAS可以将read-modify-write操作转换为原子操作,且由CPU直接保证原子性。

CAS操作有三个操作数:内存值V,旧的预期值A,要修改的新值B。当且仅当旧预期值A和内存值V相同时,将内存值V修改为B并返回true,否则什么都不做,并返回false。

例如,在多线程环境下,线程1和线程2操作同一个内存变量a:

  1. 线程1和线程2从主内存中获取变量a的值到各自的工作内存中。
  2. 线程1使用CAS操作,比较工作内存中的旧值A与主内存中的值V,如果相等则将新值B更新到主内存中。
  3. 如果线程2再次尝试CAS操作,但发现主内存中的值已被线程1修改,操作失败。
  4. 线程2再次读取主内存的值,再次尝试CAS操作,直到操作成功或达到尝试次数上限为止。

CAS的重要应用之一是在多线程环境下保证对共享数据的原子操作,避免了传统锁机制的开销和复杂性。在并发编程中,CAS有广泛的应用。Java中的Atomic包就是基于CAS实现的原子操作类。

谈谈JMM(Java 内存模型)

Java内存模型 (Java Memory Model, JMM) 是定义了 Java 程序中各种变量(包括线程共享变量)的访问规则以及变量存储与读取细节的一种模型。绝大多数现代编程语言都有类似的内存模型。

JMM 的特点包括:

  • 共享变量存储于主内存(计算机的 RAM)中。这里的共享变量指的是实例变量和类变量,不包括局部变量,因为局部变量是线程私有的,不存在竞争问题。
  • 每个线程都有自己的工作内存,它包含了被线程使用的变量的工作副本。
  • 线程对变量的所有操作(读写)必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程间也不能直接访问对方工作内存中的变量;线程间变量的值的传递需要通过主内存完成。

Java内存模型通过定义这些规则,确保了多线程环境下对共享变量的正确访问。同时,开发人员需要遵守这些规则来确保程序的正确性和可靠性。

说得更简单一点,Java内存模型定义了多线程环境下内存的工作方式,以及线程如何访问和交互共享变量。这有助于确保程序的正确性和可靠性。

synchronized、volatile 和 lock 的区别

synchronized、volatile、和lock是Java 中用于多线程编程的关键字和工具,它们在实现线程同步和数据可见性方面起着不同的作用。

  • synchronized:synchronized 是 Java 中的关键字,可以用于方法或代码块上。当 synchronized 用于方法时,它锁住的是整个方法,当用于代码块时,它锁住的是括号内的对象。synchronized 提供了互斥锁,确保同时只有一个线程可以访问同步代码块或方法,从而避免多个线程同时访问共享资源。synchronized 是基于对象锁的,每个对象都有一个与之相关联的锁。在多线程环境下,使用 synchronized 可以确保线程之间的安全访问共享变量。

  • volatile:volatile 是 Java 中的关键字,用于声明变量时指示编译器不要执行任何针对这个变量的优化,确保多线程访问时变量的可见性。被 volatile 修饰的变量在每次被线程读取时都会从主内存中重新获取最新的值,因此保证了线程之间对变量修改的可见性。但是 volatile 不能保证原子性,也就是不能保证多个线程同时修改变量时的线程安全性。

  • Lock:Lock 是 Java 中的一个接口,用于替代 synchronized 线程锁,它有很多实现类,如 ReentrantLock、Condition 等。相比 synchronized,Lock 提供了更丰富的同步操作,例如可以实现公平锁、可重入锁、尝试获取锁、定时获取锁等功能。与 synchronized 不同,Lock 是显示锁,需要手动加锁和释放锁,因此更加灵活。但同时使用 Lock 也需要开发者自行确保在获取锁后及时释放锁,以避免死锁等问题。

综上所述,synchronized 提供了隐式锁机制,使用方便,但功能相对较为简单;volatile 提供了变量的可见性,但不能保证线程安全性;而 Lock 提供了更加强大、灵活的锁机制,可以实现更多复杂的同步需求。在实际开发中,应根据具体的业务需求和线程安全问题选用适当的方式实现线程同步和数据可见性。

特性synchronizedvolatileLock
使用方式关键字关键字接口
锁类型隐式锁N/A显式锁
锁范围方法或代码块变量方法
锁特性提供互斥锁,确保线程安全保证线程之间变量改动的可见性提供了更丰富的同步操作,可实现更多复杂的需求
可重入性可重入N/A可重入
锁释放自动释放N/A手动释放
功能实现简单,使用方便提供可见性,不能保证线程安全性提供更加灵活的锁机制,可实现更多复杂的需求

Java 中有哪些锁

在Java中,锁是多线程编程中常用的一种同步机制。以下是几种常见的锁和其特点:

  • 悲观锁和乐观锁

    • 悲观锁会认为在使用数据时一定会有别的线程来修改数据,所以在操作数据之前会先加锁,以确保数据不会被其他线程修改。synchronized关键字和Lock的实现类都是悲观锁的实现方式。
    • 乐观锁则认为在使用数据时不会有别的线程修改数据,因此不会添加锁,而是在更新数据时判断之前是否有其他线程更新了数据。CAS算法和版本号控制都是乐观锁的实现方式。适用于读操作较多的情况。
  • 自旋锁

    • 自旋锁是一种获取不到锁时会进行忙等待的锁,即线程会循环检查锁是否可以被获取,而不会进入阻塞状态。这可以减少线程状态的切换,但如果某个线程持有锁的时间过长,会导致其他等待获取锁的线程进入忙等待,消耗CPU资源。
  • 读写锁

    • 读写锁是一种可以允许多个读线程访问数据的锁,但在有写线程访问时,所有的读线程和写线程都会被阻塞。它通过分离读锁和写锁来提高并发性能。
  • 可重入锁和非可重入锁

    • 可重入锁(也称为递归锁)指的是在同一个线程在外层方法获取锁后,再进入该线程的内层方法会自动获取锁,不会因为之前已经获取过还没释放而阻塞。ReentrantLock 和 synchronized 都是可重入锁的实现方式。
    • 非可重入锁则表示获取锁后不能再次获取,否则会导致死锁。

其他线程问题

ThreadLocal

ThreadLocal 的作用是实现资源对象的线程隔离,让每个线程都可以拥有自己的资源对象,避免了线程安全问题,并且同时实现了资源对象在线程内的共享。

其原理是通过 ThreadLocalMap 在每个线程内存储资源对象,并使用 ThreadLocal 自身作为 key 来获取和移除对应的资源值。

ThreadLocalMap 中的 key 被设计为弱引用,以便在内存不足时能释放其占用的内存,同时使用启发式扫描来清除临近的 null key 的 value 内存。

推荐使用 ThreadLocal,并把它作为静态变量来使用,因为无法被动依靠 GC 回收。同时,在实际使用中要注意主动移除 key,value,来释放相应的内存。

CyclicBarrier 和 CountDownLatch

CyclicBarrier和CountDownLatch是两个在java.util.concurrent包下的类,都用于表示代码运行到某个点上的情况,但它们有以下区别:

  • CyclicBarrier: 当某个线程运行到某个点后,该线程停止运行,直到所有线程都到达这个点,然后所有线程才会重新运行;CountDownLatch: 当线程运行到某个点后,只是将某个数值-1,然后该线程继续运行。

  • CyclicBarrier只能唤起一个任务,而CountDownLatch可以唤起多个任务。

  • CyclicBarrier是可重用的,而CountDownLatch在计数值减到0之后就不可再使用了。

方面CyclicBarrierCountDownLatch
激活所有线程必须到达屏障点单个或多个线程到达倒计数点
可重用性在屏障被打破后可重复使用当计数值减到0后不可再使用
用途适用于线程之间的同步适用于依赖其他任务完成的任务
任务数量适用于固定数量的任务适用于动态数量的任务
文章来源:https://blog.csdn.net/m0_56925275/article/details/135554678
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。