Java并发八股

本文最后更新于 2024年8月12日 中午

Java并发八股

什么是原子性?举例说明

原子性是并发编程中的一个关键概念,它的意思是一个操作要么完全执行,要么完全不执行,不会被其他线程中断。

例如,假设我们有一个简单的操作:i++,这个操作看起来是原子的,但实际上它不是。这个操作至少包含以下三个步骤:读取变量i的值,将值加1,然后将新的值写回内存。在并发环境中,如果这个操作不是原子的,那么可能会出现问题。例如,两个线程同时读取变量i的值,然后都将其加1,然后写回内存,这样变量i的值只增加了1,而不是2,这就是所谓的竞争条件。

在Java中,对基本数据类型(除了long和double)的读取和写入操作是原子的。但是,像i++这样的复合操作不是原子的,需要使用synchronized关键字或者java.util.concurrent包中的类(如AtomicInteger)来保证其原子性。

i++ 和 i–操作是否具备原子性

不具备

这个过程主要涉及三个操作

  • 读取变量i的值
  • 计算i操作之后的值
  • 将i的值写回内存

解释可见性在并发编程中的含义

在并发编程中,可见性是一个非常重要的概念。当我们谈论”可见性”时,我们讨论的是一个线程修改的状态对于另一个线程是什么时候可见的,即一个线程对共享变量值的修改何时能够被其他线程看到

这是一个关键的问题,因为在现代计算机系统中,每个CPU都有缓存(Cache)。为了提高性能,系统通常会将主内存中的数据缓存到CPU近距离的缓存中。如果一个线程在CPU A上运行,并修改了一个变量,这个变量的新值可能会被存储在CPU A的缓存中,而不是主内存中。此时,如果另一个线程在CPU B上运行,并试图读取这个变量,它可能会看到这个变量的旧值。

为了解决这个问题,Java提供了一些机制来确保可见性,如volatile关键字、synchronized关键字和java.util.concurrent包中的类。例如,如果一个变量被声明为volatile,那么JVM就会确保任何对这个变量的写入操作都会立即刷新到主内存中,任何读取这个变量的操作都会从主内存中读取最新的值,从而保证了变量值的可见性

有哪些方法可以保证变量的可见性

主要有以下几种方式来保证数据在多线程环境下的可见性:

  • Synchronized:synchronized关键字可以确保可见性。当一个线程进入一个synchronized方法或块时,它会读取变量的最新值。当线程退出synchronized方法或块时,它会将在此方法或块内对这些变量的任何更改写入主内存。因此,synchronized不仅可以保证原子性,也可以保证可见性。
  • Volatile:volatile关键字也可以确保可见性。如果一个变量被声明为volatile,那么JVM就会确保任何对这个变量的写入操作都会立即刷新到主内存中,任何读取这个变量的操作都会从主内存中读取最新的值。
  • Final:对于final字段,JVM确保初始化过程的安全发布,这意味着一旦构造函数设置了final字段的值,任何线程都可以看到这个字段的正确值。
  • 使用java.util.concurrent包中的类:Java提供了一些并发工具类,如AtomicIntegerAtomicLong等,这些类内部都有保证可见性的机制。

final关键字是否能确保可见性

是的,final关键字可以保证可见性

在Java中,final关键字用于声明一个常量,也就是说,一旦赋值后,就不能再改变。这个特性使得final字段在构造函数中赋值后,所有线程都可以看到这个字段的正确值,从而保证了可见性。

具体来说,当一个对象被创建时,如果它的final字段在构造函数中被初始化,那么当构造函数结束时,任何获取到该对象引用的线程都将看到final字段已经被初始化完成的值,即使没有使用锁或者其他同步机制。

这是因为Java内存模型为final字段提供了一个重排序规则:在构造函数中对final字段的写入,和随后把这个被构造对象的引用赋给一个引用变量,这两个操作不能重排序。这就保证了一旦一个对象被构造完成,并且该对象的引用被别的线程获得,那么这个线程能看到该对象final字段的正确值。

为何需要使用多线程进行程序设计

多线程的主要用途是提高应用程序的性能和响应速度。

  • 利用多核CPU资源:在现代多核CPU硬件上,多线程可以帮助我们充分利用CPU资源,实现并行处理,提高程序的执行效率。比如,如果你需要执行一个复杂的计算任务,你可以将其拆分成多个子任务,然后并行的在多个线程上执行,从而提高整体的执行速度。
  • 提高响应性:在某些情况下,我们可能希望一部分代码能够立即响应用户的交互,而不必等待其他耗时的操作完成。比如,一个文本编辑器在保存大文件时,我们并不希望整个界面冻结,无法进行编辑或者响应其他用户操作。这种情况下,我们可以将文件保存的操作放在一个单独的线程中执行,主线程则继续响应用户的其他操作。
  • 简化编程模型:在某些情况下,多线程可以使得程序设计变得更加简单。比如,一个服务器程序需要同时处理多个客户端的请求,采用多线程模型,每到来一个请求就启动一个线程进行处理,可以使得程序设计变得简单直接。

列举创建线程的几种不同方法

  • 继承Thread类:创建一个新的类作为Thread类的子类,然后重写Thread类的run()方法,将创建的线程要执行的代码放在run()方法中。然后创建子类的实例并调用其start()方法来启动线程。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class MyThread extends Thread {
    public void run(){
    // 代码
    }
    }
    public class Main {
    public static void main(String[] args) {
    MyThread t = new MyThread();
    t.start();
    }
    }
  • 实现Runnable接口:创建一个新的类来实现Runnable接口,然后重写Runnable接口的run()方法。然后创建Runnable子类的实例,并以此实例作为Thread的参数来创建Thread对象,该Thread对象才是真正的线程对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class MyRunnable implements Runnable {
    public void run(){
    // 代码
    }
    }
    public class Main {
    public static void main(String[] args) {
    MyRunnable r = new MyRunnable();
    Thread t = new Thread(r);
    t.start();
    }
    }
  • 实现Callable和Future接口:与Runnable相比,Callable可以有返回值,返回值通过FutureTask进行封装。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class MyCallable implements Callable<Integer> {
    public Integer call() {
    // 代码,返回值为Integer
    }
    }
    public class Main {
    public static void main(String[] args) throws Exception {
    MyCallable c = new MyCallable();
    FutureTask<Integer> task = new FutureTask<>(c);
    new Thread(task).start();
    Integer result = task.get(); //获取线程返回值
    }
    }
  • 使用线程池:Java 1.5开始,可以通过Executor框架在Java中创建线程池。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Main {
    public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 10; i++) {
    executor.execute(new Runnable() {
    public void run() {
    // 代码
    }
    });
    }
    executor.shutdown();
    }
    }

守护线程是什么,它与普通线程有何不同

在Java中,线程分为两种类型:用户线程和守护线程

守护线程是一种特殊的线程,它在后台默默地完成一些系统性的服务,比如垃圾回收线程,JIT线程就可以理解为守护线程。这些线程并不属于程序中不可或缺的部分。因此,当所有的用户结束时,Java虚拟机也就退出了。守护线程并不会阻止Java虚拟机退出。

设置守护线程的方法是调用Thread对象的setDaemon(true)方法。需要注意的是,一定要在调用线程的start()方法之前设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DaemonThreadExample extends Thread {
public void run() {
while (true) {
processSomething();
}
}

private void processSomething() {
// processing some job
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
Thread t = new DaemonThreadExample();
t.setDaemon(true);
t.start();
// continue program
// daemon thread will automatically exit when all user threads are done.
}
}

线程的状态有哪些 ?它们是如何转换的

Java中的线程状态

线程状态的转换关系如下:新建状态通过start()方法转换为就绪状态,就绪状态通过获取CPU资源转换为运行状态,运行状态通过yield()方法可以转换为就绪状态,运行状态通过sleep()、wait()、join()、阻塞I/O或获取不到同步锁可以转换为阻塞状态,阻塞状态解除阻塞后可以转换为就绪状态,运行状态结束生命周期转换为死亡状态。

线程的优先级对线程执行有什么影响

线程的优先级是一个整数,其范围从Thread.MIN_PRIORITY(值为1)到Thread.MAX_PRIORITY(值为10)。默认的优先级是Thread.NORM_PRIORITY(值为5)。

线程优先级的主要作用是决定线程获取CPU执行权的顺序。优先级高的线程比优先级低的线程会有更大的可能性获得CPU的执行时间,也就是说优先级高的线程更有可能先执行。但是需要注意的是,线程优先级并不能保证线程的执行顺序,线程的调度行为依赖于操作系统的具体实现

在Java中,我们可以通过Thread类的setPriority(int newPriority)方法来设置线程的优先级,通过getPriority()方法来获取线程的优先级。

i++操作在多线程环境下是否安全?为什么

i++并不是线程安全的。

i++这个操作实际上包含了三个步骤:读取i的值,对i加1,将新值写回到i。在多线程环境下,这三个步骤可能会被打断

例如,一个线程在读取了i的值并且加1之后,但还没来得及将新值写回i,这时另一个线程也来读取i的值并加1,然后写回i,这时第一个线程再将它计算的值写回i,就会覆盖掉第二个线程的计算结果,导致实际上i只增加了1,而不是2。这就是所谓的线程安全问题。

对于这种情况,我们通常会使用同步机制(如synchronized关键字)或者使用原子操作类(如AtomicInteger)来保证操作的原子性,从而避免线程安全问题。

1
2
3
4
5
6
7
8
9
import java.util.concurrent.atomic.AtomicInteger;

public class Main {
private static AtomicInteger atomicI = new AtomicInteger(0);

public static void safeIncrement() {
atomicI.incrementAndGet();
}
}

如何保证三个线程按照特定顺序执行

  • 使用线程的 join 方法
  • 使用对象的 wait 方法
  • 使用重入锁 Condition 的 await 方法
  • 使用 Executors.newSingleThreadExecutor0 创建一个单线程的线程池

join方法的作用是什么

字符串join和线程中的join方法

如何让一个正在执行的线程暂停

可以使用TimeUnitsleep()方法来让线程休眠,这个方法接受一个表示休眠时间的参数,和一个表示时间单位的TimeUnit对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.concurrent.TimeUnit;

public class SleepExample {

public static void main(String[] args) {
Thread task = new Thread(() -> {
System.out.println("Task started");
try {
TimeUnit.SECONDS.sleep(2); // 休眠2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task finished");
});

task.start();
}
}
/*
输出结果:
Task started
(等待2秒)
Task finished
*/

启动线程应该调用start方法还是run方法,为什么

要启动一个线程,您应该使用start()方法,而不是直接调用run()方法。当您调用start()方法时,Java虚拟机会为该线程分配新的系统资源和调用栈,然后调用线程的run()方法。这样,run()方法就会在新的线程中执行,实现了多线程的目的。

如果您直接调用run()方法,那么run()方法将在当前线程中执行,而不会启动新的线程。这样,实际上您的程序将变成单线程,无法实现并发执行

1
2
3
4
5
6
7
8
9
10
public class StartExample {

public static void main(String[] args) {
Thread task = new Thread(() -> {
System.out.println("Task is running in thread: " + Thread.currentThread().getName());
});

task.start(); // 使用 start() 方法启动线程
}
}

如果一个线程多次调用start方法会出现什么情况

一个线程多次调用start()方法会抛出IllegalThreadStateException异常。线程一旦启动(通过调用start()方法),就不能再次启动。如果您尝试多次启动同一个线程,Java会认为这是一个非法操作,因此会抛出IllegalThreadStateException

这是因为线程的生命周期中,线程只能从”新建”状态变为”可运行”状态一次。一旦线程开始执行(进入”可运行”状态),它将无法返回到”新建”状态。当线程执行完毕(进入”终止”状态)后,也不能再次启动。

start方法和run方法的区别

start()方法和run()方法都是Thread类的方法,它们的主要区别在于如何执行线程的任务:

  • start()方法:当调用start()方法时,Java虚拟机会创建一个新的线程,并为该线程分配新的系统资源和调用栈,然后调用线程的run()方法。这样,run()方法就会在新的线程中执行,实现了多线程的目的。
  • run()方法:如果直接调用线程的run()方法,那么run()方法将在当前线程中执行,而不会启动新的线程。这样,实际上你的程序将变成单线程,无法实现并发执行。

sleep方法和wait方法的区别

sleep()wait()方法都可以让线程暂停执行,区别如下:

  • 来源sleep()方法是Thread类的静态方法,而wait()方法是Object类的实例方法。这意味着所有Java对象都可以调用wait()方法,而只有Thread类及其子类可以调用sleep()方法。
  • 锁释放:当线程调用sleep()方法时,它不会释放已经持有的任何对象锁。因此,如果线程在调用sleep()之前获取了锁,其他线程将无法访问受该锁保护的资源,直到睡眠时间结束。而当线程调用wait()方法时,它会释放持有的对象锁,允许其他线程访问受锁保护的资源。
  • 唤醒机制sleep()方法在指定的时间(毫秒)后自动唤醒线程。而wait()方法需要依赖其他线程调用相同对象的notify()notifyAll()方法来唤醒等待的线程。如果没有其他线程调用这些方法,调用wait()的线程将一直等待下去。
  • 使用场景sleep()方法通常用于让线程暂停执行一段时间,以便其他线程执行或等待某些条件成熟。例如,在轮询某一资源时,可以让线程每隔一段时间检查一次资源状态。而wait()方法通常用于线程间的协作,一个线程在等待某个条件满足时调用wait()进入等待状态,而另一个线程在条件满足时调用notify()notifyAll()来唤醒等待的线程。

Thread.yield方法的作用是什么

Thread.yield()是一个静态方法,用于暂停当前执行的线程,让出CPU的使用权,使得其他具有相同优先级的线程得以执行。如果没有其他相同优先级的线程需要执行,或者所有其他线程的优先级都比当前线程低,那么yield()方法可能无效

值得注意的是,yield()方法并不会使线程进入阻塞状态,也不会释放已经持有的锁。线程在yield()后仍然处于可运行状态,只是优先级被降低,让出了CPU,等待下次调度。

yield方法和sleep方法有何不同

  • 阻塞与否:sleep()方法会使得线程进入阻塞状态,即使CPU资源充足,线程在指定的睡眠时间内也不会被调度执行。而yield()方法只是让出CPU,线程仍然处于可运行状态,一旦CPU资源充足,线程可能马上就会被调度执行。
  • 可控性:sleep()方法的睡眠时间是可以精确控制的,而yield()方法是否真正让出CPU,以及让出多长时间,都取决于系统的具体调度策略,无法精确控制。
  • 使用场景:sleep()方法常用于让线程暂停执行一段时间,例如在轮询某一资源时,可以让线程每隔一段时间检查一次资源状态。而yield()方法通常用于调试和测试,或者在某些特定的并发场景下,为了提升系统的整体效率或公平性,手动调整线程的执行顺序。

如何理解Java中的中断机制

线程中断是 Java 多线程编程中一种重要的机制,它提供了一种让一个线程请求另一个线程停止执行的方式。这种机制并不是强制性的,而是一种协作式的方式。当一个线程想要中断另一个线程时,实际上是给那个线程设置一个中断标志,表示希望它停止执行。被中断的线程需要定期检查这个标志,并决定是否响应中断请求。

image-20240811102105073

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//假设我们有一个线程,它会无限循环地执行一些任务。我们希望在某个时刻停止这个线程。
class MyRunnable implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 执行任务
}
System.out.println("线程已中断");
}
}

public class Main {
public static void main(String[] args) throws InterruptedException {
Thread myThread = new Thread(new MyRunnable());
myThread.start();

// 主线程休眠 5 秒,让 myThread 有时间执行任务
Thread.sleep(5000);

// 中断 myThread 线程
myThread.interrupt();
}
}

wait、notify、notifyAll方法的用途及原理

wait(), notify()notifyAll() 是 Java 中的 Object 类的方法,主要用于线程间的通信。

  • wait() 方法可以使当前的线程处于“等待”状态,同时也会让当前的线程释放它所持有的锁。
  • notify() 方法则会随机唤醒一个处于等待状态的线程,使其进入“可运行”状态。
  • notifyAll() 方法则会唤醒所有处于等待状态的线程。

这三个方法必须在 synchronized 块或者方法中使用,否则会抛出 IllegalMonitorStateException 异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/*有一个生产者-消费者模型,生产者负责生产商品,消费者负责消费商品。当商品库存为0时,消费者需要等待生产者生产商品,这时就可以调用 wait() 方法让消费者线程等待;当生产者生产了商品后,可以调用 notify() 或 notifyAll() 方法唤醒消费者线程。*/
class Store {
private int product = 0;

public synchronized void produce() {
if(product >= 5) { // 如果已经有5个产品了,就等待
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
product++;
System.out.println("生产者生产了一件商品");
notifyAll(); // 通知等待的消费者可以取商品了
}

public synchronized void consume() {
if(product <= 0) { // 如果没有产品了,就等待
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
product--;
System.out.println("消费者消费了一件商品");
notifyAll(); // 通知等待的生产者可以生产商品了
}
}

编写一个示例程序,使用三个线程打印1-100

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
```

### 编写一个示例程序,展示如何使用 wait 方法使线程等待。

```java
public class WaitNotifyExample {

// 共享资源
private static Object lock = new Object();
private static boolean ready = false;

public static void main(String[] args) throws InterruptedException {

// 创建并启动等待线程
Thread waitingThread = new Thread(() -> {
synchronized (lock) {
while (!ready) {
try {
System.out.println("等待线程:等待中...");
lock.wait(); // 等待通知
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("等待线程:收到通知,继续执行...");
}
});

// 启动等待线程
waitingThread.start();

// 让等待线程有机会进入等待状态
Thread.sleep(1000);

// 创建并启动通知线程
Thread notifyingThread = new Thread(() -> {
synchronized (lock) {
System.out.println("通知线程:准备通知等待线程...");
ready = true;
lock.notify(); // 发出通知
}
});

// 启动通知线程
notifyingThread.start();
}
}

编写一个实例程序,展示如何产生多线程死锁情况

在这个例子中,thread1Proc方法首先锁定resource1,然后尝试锁定resource2。同时,thread2Proc方法首先锁定resource2,然后尝试锁定resource1。由于每个线程都持有另一个线程需要的资源,并且都在等待获取另一个资源,因此它们将永远等待下去,从而导致死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class DeadlockExample {

// 创建两个资源
private final Object resource1 = new Object();
private final Object resource2 = new Object();

public void thread1Proc() {
synchronized (resource1) {
System.out.println("线程1: 锁定资源 1");

try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (resource2) {
System.out.println("线程1: 锁定资源 2");
}
}
}

public void thread2Proc() {
synchronized (resource2) {
System.out.println("线程2: 锁定资源 2");

try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

synchronized (resource1) {
System.out.println("线程2: 锁定资源 1");
}
}
}

public static void main(String[] args) {
DeadlockExample deadlock = new DeadlockExample();

// 创建线程1
new Thread(() -> {
deadlock.thread1Proc();
}, "线程1").start();

// 创建线程2
new Thread(() -> {
deadlock.thread2Proc();
}, "线程2").start();
}
}

请解释AtomicInteger类的底层实现原理

AtomicInteger是Java并发编程中的一个类,它提供了一种线程安全的方式来执行整数的原子操作。所谓原子操作,就是指一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始,就不会被其他线程干扰。

AtomicInteger的底层实现主要依赖于硬件级别的CAS(Compare and Swap)操作。

CAS操作包含三个操作数:内存值V、预期原值A、新值B。当内存值V等于预期原值A时,将内存值修改为B并返回true,否则什么都不做并返回false。这个操作是原子的。

AtomicInteger的大部分方法,如getAndIncrement、getAndDecrement、getAndAdd等,都是通过无限循环来调用CAS操作实现的。在循环中,首先获取当前的值,然后计算新的值,接着用CAS操作尝试更新值。如果更新成功,循环结束;如果更新失败,说明值在此期间被其他线程修改过,那么就继续下一次循环,重新获取值,重新计算,重新尝试更新。

什么是CAS操作?它在并发编程中有什么作用

CAS 是 Compare and Swap 的缩写,中文意思是“比较并交换”。它是一种用于实现多线程同步的技术,主要用于解决多线程并发情况下的数据一致性问题

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。当且仅当内存位置的值与预期原值相匹配时,才会将该位置的值更新为新值。否则,就什么都不做。最后,CAS 操作总是返回该位置的旧值。

CAS操作有哪些缺点

  • ABA 问题:CAS 能检测到内存值是否发生了变化,但无法检测到变化的过程。假设内存值原来是 A,后来被某个线程改为了 B,再被另一个线程改回了 A。这种情况下,CAS 会认为内存值没有发生变化,从而导致潜在的问题。解决 ABA 问题的一种方法是使用版本号或时间戳来标记内存值的变化
  • 自旋开销:当 CAS 操作失败时,通常需要不断重试,这就是所谓的自旋。在高并发场景下,如果某个线程长时间无法成功执行 CAS 操作,就会导致 CPU 资源的浪费。解决这个问题的一种方法是使用自适应的自旋策略,例如限制自旋次数或根据线程的优先级调整自旋时间
  • 只能保证一个共享变量的原子操作:CAS 只能保证对单个共享变量的原子操作,如果需要对多个共享变量进行原子操作,就无法直接使用 CAS。这时可以使用锁机制或者将多个共享变量封装成一个对象,然后使用 CAS 操作对象的引用。

用伪代码描述CAS算法的核心操作

1
2
3
4
5
6
7
function CAS(V, A, B):
if V == A: # 如果 V 的值等于预期值 A
V = B # 则将 V 的值更新为 B
return true # 返回 true 表示更新成功
else:
return false # 返回 false 表示更新失败

在多线程环境下进行数字累加(如count++)操作时需要注意哪些问题

  • 线程安全:count++ 并不是一个原子操作,它包括三个步骤:读取 count 的值,将值加一,写回新值。在多线程环境中,如果不进行特殊处理,多个线程可能会同时读取和修改 count 的值,导致结果错误。这就是所谓的竞态条件。
  • 同步机制:为了解决线程安全问题,我们需要使用某种同步机制来确保每次只有一个线程能够执行 count++ 操作。常见的同步机制包括互斥锁(例如 Java 中的 synchronized 关键字)和信号量。
  • 性能问题:使用同步机制可以确保线程安全,但可能会带来性能问题。例如,如果我们使用互斥锁来保护 count++ 操作,那么每次只有一个线程能够执行这个操作,其他线程都需要等待,这会降低并发性能。
  • 原子操作类:Java 提供了一些原子操作类,如 AtomicInteger,可以用来替代 count++ 操作,这些类内部已经处理了线程安全问题,并且通常性能比使用互斥锁更好。

既然有了AtomicInteger,为什么还要引入LongAddr类

总的来说,如果累加操作的并发度非常高,或者需要进行大量的累加操作,建议使用 LongAdder。如果并发度较低,或者除了累加操作外还需要执行其他复杂的原子操作,可以选择 AtomicInteger。

AtomicInteger 是一个很好的并发工具,但在高并发情况下,如果有大量线程同时进行更新操作,会导致大量的 CAS 操作失败并自旋重试,这会消耗大量的 CPU 资源,影响性能。LongAdder 在高并发情况下可以提供更好的性能,但在并发度较低时,由于需要维护更多的数据结构,其性能可能略低于 AtomicInteger。

为什么不建议使用stop方法来停止线程,有其他更好的方案吗

  • 安全性问题:Thread.stop()方法会导致线程突然停止,使得线程无法完成清理工作,例如关闭打开的文件或者释放分配的资源,这可能导致应用状态不一致或者资源泄露。
  • 数据一致性问题:Thread.stop()方法会立即终止线程,如果线程正在执行一个原子操作(比如更新两个相关的变量),那么这个操作可能只完成了一半,导致数据处于不一致的状态。

使用中断机制来实现停止线程的运行

image-20240811120038595

说一说可重入锁

可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。

重入”(Reentrant)是指一个线程在已经持有某个锁的情况下,可以再次获取同一个锁,而不会发生死锁。换句话说,重入锁允许一个线程多次获取同一个锁。在Java中,ReentrantLock和synchronized关键字都是重入锁的实现。

ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。

  • 当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。
  • 当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。

ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。

重入锁的用法(了解即可)

重入锁最多可以重入多少次,是否存在限制

Java中的ReentrantLock没有明确限制一个线程可以重入多少次,理论上只要不发生溢出,就可以无限次重入。但实际上,由于ReentrantLock内部使用int类型来维护重入次数,所以最多可以重入2^31次(即Integer.MAX_VALUE次,大约是20亿次)。超过这个次数,内部的计数器将会溢出。

什么是公平锁和非公平锁

这两种锁的主要区别在于它们如何管理等待获取锁的线程。

  • 公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。
  • 非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。

非公平锁吞吐量为什么比公平锁大

  • 公平锁执行流程:获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢
  • 非公平锁执行流程:当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率

synchronized关键字是否实现了一种重入锁?解释其实现原理

Synchronized确实是一种重入锁。重入锁,顾名思义,就是一个线程可以多次获得同一把锁。在Java中,synchronized关键字提供的锁就是重入锁。

image-20240811161957424

synchronized与ReentrantLock在使用上有哪些区别?

synchronizedReentrantLock都是Java中的同步机制,用于控制多线程对共享资源的访问

  • 锁的获取和释放synchronized不需要用户手动去获取锁和释放锁,当进入和退出synchronized修饰的代码块时,锁的获取和释放都是隐式的。而ReentrantLock需要用户手动获取和释放锁,如果没有正确释放锁,可能会导致死锁。这就需要在finally块中释放锁。
  • 等待可中断:在ReentrantLock中,等待锁的过程可以被中断,并且可以知道是哪个线程被中断。而在synchronized中,等待的线程不能被中断。
  • 公平锁ReentrantLock支持公平锁和非公平锁。公平锁是指多个线程按照申请锁的顺序来获取锁。而synchronized只支持非公平锁。
  • 锁绑定多个条件ReentrantLock可以绑定多个Condition对象,实现多路通知。也就是可以在一个ReentrantLock对象上,为多个线程建立不同的等待线程队列。而synchronized中,锁对象的waitnotifynotifyAll方法可以实现一个等待队列。
  • 锁的性能:在Java 1.6及其之后的版本,synchronized在锁的性能优化方面做了很多工作,例如偏向锁、轻量级锁等,性能已经不再是选择synchronizedReentrantLock的决定因素。但在具体使用时,ReentrantLock的灵活性会更胜一筹。

synchronized关键字作为同步锁有哪些用法

在Java中,synchronized关键字主要有三种用法,分别可以修饰方法、代码块以及静态方法或静态代码块

  • 修饰普通方法:当synchronized修饰普通方法时,锁是当前实例对象。同一时间只有一个线程能够进入该方法,其他线程必须等待。

    1
    2
    3
    4
    5
    public class MyClass {
    public synchronized void method() {
    // ...
    }
    }
  • 修饰代码块synchronized可以修饰代码块,此时需要指定一个锁对象。在同一时刻,只有一个线程能够进入该代码块,其他线程必须等待。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class MyClass {
    private Object lock = new Object();

    public void method() {
    synchronized(lock) {
    // ...
    }
    }
    }
  • 修饰静态方法或静态代码块:当synchronized修饰静态方法或静态代码块时,锁是当前类的Class对象。在同一时刻,只有一个线程能够进入该静态方法或静态代码块,其他线程必须等待。

    1
    2
    3
    4
    5
    public class MyClass {
    public static synchronized void method() {
    // ...
    }
    }
    1
    2
    3
    4
    5
    6
    7
    public class MyClass {
    static {
    synchronized(MyClass.class) {
    // ...
    }
    }
    }

synchronized关键字如何保证变量的可见性

在Java内存模型中,所有的变量都存储在主内存中,每个线程都有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

synchronized的可见性是通过对一个线程修改后的变量的值进行同步,从而使其他线程能够看到修改后的值。

具体实现上,当一个线程进入synchronized同步代码块时,同步代码块内部的变量会从主内存中读取到工作内存。当线程释放锁时,同步代码块内部的新值会被刷新回主内存。当另一个线程进入同步代码块时,可以看到之前已经被刷新回主内存的变量的新值。

这样,通过synchronized,不同的线程能够及时地看到共享变量的最新值,从而达到可见性。

synchronized关键字如何保证代码块的有序性执行

synchronized是通过锁的机制实现有序性的。当一个线程获取到一个synchronized锁时,其他线程必须等待该锁被释放后才能获取该锁。这样,synchronized锁内的代码(临界区)在同一时刻只会被一个线程执行,从而保证了代码执行的有序性。

锁的升级的过程讲一下

锁升级是Java虚拟机(JVM)对于锁的一种优化策略,主要用于在不同场景下选择合适的锁类型,以提高多线程环境下的性能。

锁的升级

Java中对synchronized锁进行了哪些优化

解释读写锁(ReadWriteLock)的概念及其工作原理

读写锁(ReadWriteLock)是一种用于解决多线程并发访问共享资源的同步策略。它允许多个线程同时读取共享资源,但是在写入(修改)共享资源时,只允许一个线程执行。这种锁策略适用于读操作远多于写操作的场景,可以提高系统的并发性能。

  • 读锁(共享锁): 读锁允许多个线程同时读取共享资源,即它是共享的。当一个线程拥有读锁时,其他线程仍然可以获取读锁,但不能获取写锁
  • 写锁(排他锁): 写锁只允许一个线程写入共享资源,即它是排他的。当一个线程拥有写锁时,其他线程既不能获取读锁,也不能获取写锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private int data = 0;

public int readData() {
readWriteLock.readLock().lock(); // 获取读锁
try {
// 模拟读取数据
System.out.println("读取数据: " + data);
return data;
} finally {
readWriteLock.readLock().unlock(); // 释放读锁
}
}

public void writeData(int newData) {
readWriteLock.writeLock().lock(); // 获取写锁
try {
// 模拟写入数据
System.out.println("写入数据: " + newData);
data = newData;
} finally {
readWriteLock.writeLock().unlock(); // 释放写锁
}
}
}

是否有比ReadWriteLock读写锁更高效的锁机制

StampedLock是一种比ReadWriteLock更快的锁,它是Java 8中引入的新的锁机制。与ReadWriteLock相比,StampedLock提供了更高的并发性能,因为它支持乐观读模式。

StampedLock的特点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.concurrent.locks.StampedLock;

public class StampedLockExample {
private StampedLock stampedLock = new StampedLock();
private int data = 0;

public int readData() {
long stamp = stampedLock.tryOptimisticRead(); // 尝试获取乐观读锁
int currentData = data;
if (!stampedLock.validate(stamp)) { // 验证锁的状态是否已被修改
stamp = stampedLock.readLock(); // 如果已被修改,获取悲观读锁
try {
currentData = data;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
System.out.println("读取数据: " + currentData);
return currentData;
}

public void writeData(int newData) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
System.out.println("写入数据: " + newData);
data = newData;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
}

常见的锁优化策略有哪些

锁的优化策略

偏向锁是什么?请解释其工作原理和适用场景

偏向锁是Java虚拟机(JVM)为了优化无竞争同步场景下的性能开销而引入的一种锁优化技术。

当一个线程首次访问某个对象,并成功获取到锁时,锁就会进入偏向模式。在偏向模式下,锁会被标记为偏向于当前线程,以后这个线程再次请求锁时,无需进行任何同步操作,直接进入临界区。这样就避免了无竞争情况下的同步原语的开销。

当然,偏向锁并不是万能的,它只适用于只有一个线程访问同步块的场景

解释轻量级锁的概念及其在Java并发编程中的应用

轻量级锁是Java中一种锁优化策略,它的主要目的是在无竞争的情况下减少不必要的重量级锁(如synchronized)的开销。

轻量级锁的工作原理:

  • 当线程尝试获取锁时,首先检查对象头中的Mark Word是否有其他线程已经加锁。如果没有,线程将尝试使用CAS(Compare-and-Swap)操作将对象头的Mark Word设置为指向当前线程的锁记录。
  • 如果CAS操作成功,线程成功获取轻量级锁。在这种情况下,其他线程尝试获取锁时会发现对象头已被修改,因此它们会进入自旋状态,尝试在未来某个时间点再次获取锁。
  • 如果CAS操作失败,说明其他线程已经获取了轻量级锁。此时,当前线程会检查对象头中的Mark Word是否指向自己的锁记录。如果是,说明当前线程已经持有轻量级锁,可以继续执行;如果不是,则说明有竞争发生,轻量级锁会升级为重量级锁,当前线程会被阻塞。

解释重量级锁的概念及其与其他锁类型的区别

重量级锁是Java中最原始的同步机制,它是通过synchronized关键字实现的。当一个线程进入synchronized修饰的方法或代码块时,它会获取一个与该对象关联的内部锁,其他线程如果也想进入这个方法或代码块,就必须等待前一个线程释放这个锁。

在多线程环境下,重量级锁可以保证共享数据的一致性和可见性。一旦一个线程获取了重量级锁,其他线程就必须等待,无法并发执行。因此,重量级锁可以用来实现线程同步和数据的互斥访问。然而,重量级锁的开销较大,如果一个线程获取不到锁,它会被挂起并进入阻塞状态,直到其他线程释放锁为止。这种上下文切换的开销是非常大的,尤其在高并发的场景下,会大大降低系统的性能。

谈谈你对多线程中ExecutorService接口的理解

ExecutorService 提供了一系列的方法用于管理线程的生命周期,包括启动、关闭线程等。与直接创建 Thread 对象相比,使用 ExecutorService 可以提供更好的性能,特别是当程序中有大量的线程,或者每个线程的执行时间都很短的情况下。

ExecutorService中的方法

1
2
3
4
5
6
7
8
ExecutorService executor = Executors.newFixedThreadPool(10); // 创建一个包含10个线程的线程池
executor.execute(new Runnable() { // 提交并执行任务
@Override
public void run() {
System.out.println("Task is running.");
}
});
executor.shutdown(); // 关闭线程池

ExecutorService和Executors工具类在创建线程池时有何区别

简而言之,ExecutorService是一个接口,定义了线程池的操作和管理功能;而Executors是一个工具类,提供了创建线程池的便捷方法。在实际应用中,我们通常会使用Executors类来创建一个ExecutorService实例,然后使用这个实例来管理和调度线程。

两者的区别

Java标准库中提供了哪些内置线程池实现

  • FixedThreadPool:创建一个固定大小的线程池,所有线程都会被复用,如果线程池中的所有线程都在工作,新的任务会被放在一个队列中等待。这种类型的线程池适用于执行长期的任务。
  • CachedThreadPool:创建一个可以缓存线程的线程池,如果线程池的当前规模超过处理需求时,将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。这种类型的线程池适用于执行许多短期异步的小程序或者负载较轻的服务器。
  • SingleThreadExecutor:创建一个只有一个线程的线程池,这个线程池只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO,LIFO,优先级)执行。
  • ScheduledThreadPool:创建一个可以执行延迟或定时任务的线程池。
  • WorkStealingPool:这是一个在所有可用处理器上为其创建工作线程的线程池,可以用于并行执行任务。

为什么不推荐使用Executors工具类来创建线程池

  • 资源控制:Executors创建的线程池大多数是使用无界队列,比如Executors.newFixedThreadPool、Executors.newSingleThreadExecutor。这意味着如果任务提交速度持续大于处理速度,会造成队列大量积压任务,最终可能会导致OOM(Out of Memory)。
  • 参数不透明:使用Executors创建线程池,我们无法明确其默认配置,比如其默认的队列是LinkedBlockingQueue,队列大小是Integer.MAX_VALUE,这是一个几乎无限大小的队列,很容易造成OOM。
  • 灵活性较差:Executors创建的线程池,其实现细节我们无法控制,比如它默认的拒绝策略是AbortPolicy,这种策略会在拒绝任务时抛出一个未检查的RejectedExecutionException,而这可能不是我们想要的。

Java中实现异步编程有哪些方案

  • 使用线程(Thread):可以直接创建一个新的线程来执行异步任务。这是最基本的异步编程方法,但需要手动管理线程的生命周期,不适合大量并发任务。
  • 使用线程池(ExecutorService):线程池是一种更高效的异步编程方式,可以重用线程资源,减少线程创建和销毁的开销。将任务提交给线程池,线程池会自动安排线程执行任务。
  • 使用Future和Callable接口:Java提供了Future和Callable接口,可以在异步任务执行完毕后获取执行结果。将Callable任务提交给线程池,线程池会返回一个Future对象,通过Future对象可以获取异步任务的执行结果。
  • 使用CompletableFuture:Java 8引入了CompletableFuture类,它实现了Future接口,并提供了更丰富的异步编程功能,如链式调用、组合多个异步任务等。使用CompletableFuture可以更方便地实现复杂的异步逻辑。

说一下volatile的作用

  • 保证变量的可见性:当一个变量被声明为 volatile 时,它可以确保所有线程都能够看到这个变量的最新值。当一个线程修改了一个 volatile 变量的值时,其他线程在读取这个变量时,会立即看到修改后的值。这是因为 volatile 关键字禁止了指令重排序和缓存变量值,从而确保了变量的可见性。
  • 禁止指令重排序:**volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。** 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

AQS是什么

AQS,全称是AbstractQueuedSynchronizer,中文名叫做抽象队列同步器。它是用来构建锁或者其他同步组件的基础框架,JDK 5.0 在java.util.concurrent.locks包下引入了这个工具类。

AQS解决了在实现同步器时设计和实现的复杂性,它用一个int成员变量来表示同步状态,并提供了一套使用该变量的方法来实现对同步状态的操作,如获取同步状态、释放同步状态等。同时,AQS还提供了队列来进行线程的排队等待,它非常适合构建那些依赖于先进先出(FIFO)等待队列的阻塞锁和相关同步器。

AQS在Java并发编程中的实现原理

AQS(AbstractQueuedSynchronizer)的底层原理主要是基于两个核心思想:状态的管理和线程的控制

  • 状态的管理:AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state=0时表示释放了锁。它提供了三个方法(getState、setState、compareAndSetState)来对同步状态state进行操作。
  • 线程的控制:当线程尝试获取同步状态失败时,AQS能够以FIFO的顺序将当前线程添加到等待队列中。同时,当同步状态释放时,它会唤醒在等待队列中等待时间最长的线程。线程被唤醒后,重新尝试获取同步状态。

Java并发八股
https://love-enough.github.io/2024/08/04/Java并发八股/
作者
GuoZihan
发布于
2024年8月4日
更新于
2024年8月12日
许可协议