锁:
由Java 15废弃偏向锁,谈谈Java Synchronized 的锁机制 - Yano_nankai - 博客园 (cnblogs.com)
进程和线程
资源分配:进程是资源分配和调度的基本单位,线程是程序执行和调度的基本单位
- 进程是系统中的一个独立执行单位,拥有独立的地址空间、内存、文件描述符等资源。每个进程都有自己的堆栈、数据段、代码段等信息。
- 线程是进程中的执行单元,一个进程可以包含多个线程。线程共享进程的资源,包括内存、文件等。线程与进程共享代码段和数据段,这意味着线程可以直接访问相同的变量和数据;线程还共享打开的文件描述符、信号处理器和进程的用户ID和进程组ID等。
- 线程拥有自己的栈和寄存器以及程序计数器,这些用于存储线程的本地变量和程序计数器;线程的状态和程序计数器也是线程私有的;每个线程都有唯一的线程ID,用于标识自己。
并发性:进程之间可以实现操作系统的并发,线程之间可以实现进程内部的并发
- 进程间通信(IPC)通常比较复杂,需要使用特定的机制,比如管道、消息队列、共享内存等。
- 而线程之间可以通过共享内存等方式更直接地进行通信,因此线程之间的通信更加简单高效。
进程的创建和销毁开销大,线程的创建和销毁开销小
并行和并发
并行:一个时间点指的是同时执行多个任务或操作。
并发:指的是一个时间段内同时处理多个任务或操作,但这些任务的执行并不一定是同时的。
并行强调的是同时性,指的是真正同时执行多个任务;而并发强调的是交替性,指的是多个任务在时间上重叠执行,通过快速的切换实现多任务的同时执行。
同步和异步
同步:指的是任务按照顺序依次执行,一个任务的执行需要等待上一个任务完成后才能开始。调用者会阻塞等待被调用者的返回结果,直到被调用者完成任务并返回结果后,调用者才能继续执行。异步:异步指的是任务可以并行执行,不需要等待上一个任务的完成,可以在后台或其他线程中执行。调用者发起任务请求后不需要立即等待结果,而是可以继续执行其他任务。被调用者在任务完成后会通过回调函数(回调函数(callback)是什么?,回调函数是一种常见的编程模式,通常用于异步编程中。它的基本思想是将一个函数作为参数传递给另一个函数,在某个特定事件发生或条件满足时,被调用以执行相应的操作。)或其他机制通知调用者。
Java线程
在Java中,创建线程有三种常见的方法:
继承 Thread 类:
- 这是最简单的创建线程的方法。只需要创建一个类,继承自
Thread
类,并重写run()
方法来定义线程的执行逻辑。 - 通过创建这个子类的实例,调用
start()
方法来启动线程。 -
class MyThread extends Thread { public void run() { System.out.println("MyThread running"); } } public class Main { public static void main(String[] args) { MyThread thread = new MyThread(); thread.start(); // 启动线程 } }
实现 Runnable 接口:
- 这种方法更灵活,因为Java不支持多重继承,但一个类可以实现多个接口。实现
Runnable
接口并实现run()
方法。 - 创建一个实现了
Runnable
接口的类的实例,并将其作为参数传递给Thread
类的构造函数。
class MyRunnable implements Runnable {
public void run() {
System.out.println("MyRunnable running");
}
}
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start(); // 启动线程
}
}
-
也可以使用lambda简化:new Thread(() -> {System.out.println(1);}, "thread1").start();
FutureTask结合
Thread:
FutureTask
是 Java 中用于异步执行任务并获取结果的类,它实现了 RunnableFuture
接口,而 RunnableFuture
接口又继承了 Runnable
和 Future
接口。因此,FutureTask
可以被传递给 Thread
来创建多线程。
// 创建 Callable 对象
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
// 模拟耗时任务,这里假设返回一个整数结果
Thread.sleep(1000);
return 42;
}
};
// 创建 FutureTask,并传入 Callable 对象
FutureTask<Integer> futureTask = new FutureTask<>(callable);
// 创建线程,并将 FutureTask 作为参数传递给 Thread
Thread thread = new Thread(futureTask);
// 启动线程
thread.start();
// 在主线程中继续执行其他任务
System.out.printf("1");
try {
// 获取异步任务的结果,如果任务还未完成,则会阻塞等待
Integer result = futureTask.get();
System.out.println("异步任务的结果是:" + result);
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
首先创建了一个 Callable
对象,表示一个可执行的任务。然后,创建了一个 FutureTask
对象,并将 Callable
对象作为参数传递给它。接着,将 FutureTask
对象传递给 Thread
构造函数,创建了一个新的线程。最后,通过调用 start()
方法启动线程。Callable
接口与 Runnable
接口的区别在于它可以返回一个结果,并且可以抛出异常。
在主线程中,可以继续执行其他任务。当需要获取异步任务的结果时,可以调用 FutureTask
的 get()
方法,如果任务还未完成,则会阻塞等待直到任务完成并返回结果。
线程运行原理:
栈内存用于存储线程的方法调用、局部变量、参数以及方法返回值等信息。每个线程在启动时都会被虚拟机分配一块栈内存。
线程上下文切换是指在多线程环境下,由于操作系统需要调度不同的线程执行,从而导致当前执行线程的上下文(包括寄存器状态、堆栈信息等)被保存到内存中,然后加载另一个线程的上下文信息,使其可以继续执行。
以下原因导致线程切换:1.线程的cpu时间片用完2.垃圾回收3.有更高优先级的线程需要运行
4.线程自己调用了 sleep、yield、wait、join、park、synchronized、lock
等方法
常用方法
在Java中,线程类 java.lang.Thread
提供了一系列常见的方法来控制线程的行为和状态。以下是一些常见的线程方法:
-
start():
- 启动线程,使其开始执行
run()
方法中的代码。
- 启动线程,使其开始执行
-
run():
- 线程的执行逻辑通常在这个方法中实现。当调用
start()
方法启动线程时,系统会自动调用run()
方法来执行线程的任务。 - 如果创建一个线程直接使用run方法,那么还是在当前线程中调用,并没有启动新的线程,没有达到异步处理的效果。
- 线程的执行逻辑通常在这个方法中实现。当调用
-
sleep(long millis):
- 使当前线程休眠指定的时间(以毫秒为单位),在此期间线程进入阻塞状态(从
Running
进入Timed Waiting
状态(阻塞)),不会消耗CPU资源。 - 其它线程可以使用
interrupt
方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException,打断后的线程处于就绪态。
- 建议用 TimeUnit 的 sleep 代替 Thread 的
sleep
来获得更好的可读性 - Wait只能在同步代码控制块内,释放锁,来自object类,无需捕获异常。让当前线程等待,直到其它线程调用对象的notify或notifyAll方法
- 在程序的任何地方,不释放锁,来自类Thread,需要捕获异常,当前正在运行的线程主动放弃CPU,进入阻塞状态
- 使当前线程休眠指定的时间(以毫秒为单位),在此期间线程进入阻塞状态(从
-
yield():
- 提示调度器当前线程愿意放弃CPU资源,给其他线程执行的机会。但是它只是一个提示,并不保证立即生效。线程执行
yield()
方法转入就绪状态。 sleep()
方法声明抛出InterruptedException
;yield()
方法没有声明抛出异常。sleep()
方法需要指定时间参数;yield()
方法出让 CPU 的执行权时间由 JVM 控制
- 提示调度器当前线程愿意放弃CPU资源,给其他线程执行的机会。但是它只是一个提示,并不保证立即生效。线程执行
-
join()/join(long n):
- 等待该线程终止。调用线程的
join()
方法会使当前线程阻塞,直到目标线程执行完成后才会继续执行。带参数最多等待n毫秒。
- 等待该线程终止。调用线程的
-
isAlive():
- 判断线程是否还活着,即线程是否处于运行状态。如果线程已经启动且尚未终止,则返回
true
;否则返回false
。
- 判断线程是否还活着,即线程是否处于运行状态。如果线程已经启动且尚未终止,则返回
-
interrupt():
- 中断线程。当调用
interrupt()
方法时,会给线程设置一个中断标志,线程可以根据这个标志自行决定如何处理中断。 sleep,wait,join
都会让线程进入阻塞状态,打断这些出于阻塞的线程,会以异常的方式表示被打断,因此将打断标记置为假。- 打断正常运行的线程和打断LockSupport.park(), 不会清空打断状态,即为真。
- 中断线程。当调用
-
isInterrupted():
- 判断线程是否被中断。如果线程被中断,则返回
true
;否则返回false
。会清除打断标记。
- 判断线程是否被中断。如果线程被中断,则返回
-
getState():
- 获取线程状态,Java 中线程状态是用6个enum 表示,分别为:NEW,RUNNABLE.BLOCKED.WAITING. TIMED WAITING.TERMINATED
-
setName(String name) 和 getName():
- 设置和获取线程的名称。
-
park(long timeout)和unpark()
-
park()
方法的作用是使当前线程进入等待状态(阻塞),直到另一个线程调用了当前线程的unpark()
方法,或者发生了中断、超时等特定的条件才会被唤醒。打断 park 线程, 不会清空打断状态。 -
park()
方法可以带有参数,例如park(long timeout)
表示线程会被阻塞一段时间,超时后会自动唤醒。park()
方法还可以被多次调用,但是每次调用都会消耗一个许可证,unpark()
方法会恢复一个许可证。 wait()
方法需要在对象的同步代码块(synchronized)中调用,而park()
方法可以在任何地方调用。wait()
方法会释放对象的锁,而park()
方法不会释放任何锁。wait()
方法需要通过notify()
或notifyAll()
方法来唤醒等待的线程,而park()
方法则需要另一个线程调用当前线程的unpark()
方法来唤醒。
-
-
setPriority(int priority) 和 getPriority():
- 设置和获取线程的优先级。优先级范围为 1 到 10,其中1为最低优先级,10为最高优先级。
wait()和sleep()
wait()为什么在object类中而不是在thread类中,因为锁锁的是对象,而所有的对象都继承于object类。
sleep(long n)
和 wait(long n)
都是用于线程等待一段时间的方法,但是它们之间有一些重要的区别:
-
作用对象:
sleep(long n)
方法是Thread
类的静态方法,用于使当前线程休眠指定的时间(以毫秒为单位),并不释放锁资源。线程进入“TIMED_WAITING”状态。在线程休眠期间,它不会执行任何任务。wait(long n)
方法是Object
类的方法,用于使当前线程等待在对象上指定的时间(以毫秒为单位),并释放对象的锁资源。
-
使用方式:
- 调用
sleep(long n)
方法时,线程不需要获得任何对象的锁,直接调用即可。如果在休眠期间,线程被中断(调用了interrupt()
方法),则会抛出InterruptedException
异常 - 调用
wait(long n)
方法时,线程必须在同步代码块或同步方法中调用,并且需要持有该对象的锁,否则会抛出IllegalMonitorStateException
异常。
- 调用
-
唤醒条件:
- 当调用
sleep(long n)
方法后,线程会休眠指定的时间后自动唤醒,或者被其他线程调用interrupt()
方法中断。 - 当调用
wait(long n)
方法后,线程会等待指定的时间,如果超时还未被唤醒,则线程会自动苏醒。 - 当一个线程执行
wait()
方法时,它会释放对象的锁,并进入等待(阻塞)状态,直到另一个线程调用了该对象的notify()
或notifyAll()
方法来唤醒等待的线程,或者等待超时,或者线程被中断。
- 当调用
-
适用场景:
sleep(long n)
通常用于需要暂停执行一段时间的情况,比如在定时任务中、模拟延迟等场景。wait(long n)
通常用于多线程之间的协作,比如生产者-消费者模式中,生产者线程等待一段时间后自动唤醒以检查条件是否满足。
join()方法的底层实现是调用wait()。
park()和unpark()
park()
和 unpark()
是 Java 中用于线程阻塞和唤醒的方法,它们属于 java.util.concurrent.locks.LockSupport
类的一部分。
park()
方法用于阻塞当前线程,让其进入等待状态。调用park()
方法会使当前线程阻塞,直到被其他线程调用unpark(Thread thread)
方法唤醒,或者被中断。LockSupport.park()unpark(Thread thread)
方法用于唤醒指定线程。调用unpark(Thread thread)
方法可以使指定线程的许可证加一,并且如果该线程由于调用park()
方法而被阻塞,则会解除阻塞,恢复执行。
park()
和 unpark()
方法常用于实现线程间的协作机制,比如在一些并发框架和工具中,如 ForkJoinPool
、AQS(AbstractQueuedSynchronizer)
等。
与 wait()
和 notify()
相比,park()
和 unpark()
方法更加灵活和直观,因为它们不依赖于对象的监视器(锁),并且可以在任意地方调用,而不需要在同步代码块中进行操作。此外,unpark()
方法可以提前唤醒线程,即使线程还没有进入等待状态。
在 Linux 等类 Unix 系统中,park()
和 unpark()
方法是通过系统调用 futex
实现的。futex
是 Linux 内核提供的一种用户空间和内核空间共享的低级同步原语,用于实现高效的线程同步机制。在 futex
的实现中,当调用 park()
方法时,内核会将调用线程放入等待队列,并阻塞线程;而调用 unpark()
方法时,内核会从等待队列中唤醒指定的线程。
![](https://i-blog.csdnimg.cn/blog_migrate/c96fd9f83c05c723bc165660f8de5e38.png)
![](https://i-blog.csdnimg.cn/blog_migrate/da9f9781d10537f0891261af741e275e.png)
线程的状态
主线程(Main Thread):
- 主线程是Java虚拟机启动程序时创建的线程,它是程序的入口点,负责执行
main()
方法中的代码。 - 主线程通常是非守护线程(非Daemon Thread),它会等待所有的非守护线程执行完毕后才会结束,进而结束整个程序的执行。
- 主线程的结束意味着整个程序的结束。
守护线程(Daemon Thread):
- 守护线程是一种辅助线程,它的作用是为其他线程提供服务。守护线程通常是在后台运行的,不需要等待其他线程执行完毕就可以结束。
- 当所有的非守护线程结束时,守护线程会自动销毁,即使它还没有执行完毕。
- 可以通过
setDaemon(true)
方法将一个线程设置为守护线程。 - 如果守护线程因为某种原因挂掉(例如抛出了未捕获的异常导致线程终止),这并不会影响Java虚拟机的正常退出,因为守护线程的终止不会影响Java虚拟机的生命周期。虚拟机会自动检测到所有的非守护线程已经结束,然后终止程序的执行。
- 垃圾回收线程就是守护线程,Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求
- 需要注意的是,守护线程在结束时并不会执行
finally
块中的代码,因此在守护线程中执行的一些资源清理工作等应该谨慎处理
主线程和守护线程的区别在于其是否等待其他线程执行完毕。主线程会等待所有的非守护线程执行完毕后再结束,而守护线程则不会等待其他线程执行完毕就会结束。通常情况下,守护线程用于提供一些后台服务或执行一些周期性任务,它们在主线程结束时自动销毁,从而不会影响整个程序的生命周期。
线程的状态
操作系统中线程的状态一般分为五种和七种
从 Java API
层面,根据 Thread.State
枚举,分为六种状态
-
新建(New):
- 当线程对象被创建后,但还未调用
start()
方法时,线程处于新建状态。此时线程对象已经在内存中分配了资源,但尚未分配CPU执行时间。
- 当线程对象被创建后,但还未调用
-
就绪,可运行(Runnable):
- 当线程被调度器选中,具备了执行条件,等待CPU执行时,线程处于就绪状态。包括等待系统资源、等待被调度等情况。
RUNNABLE
当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了操作系统层面的就绪状态、运行状态和阻塞状态
(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行,在debug界面,显示的依旧是running)BLOCKED , WAITING , TIMED_WAITING
都是 Java API 层面对阻塞状态的细分
-
阻塞(Blocked):
- 当线程需要等待某个条件满足,比如等待I/O操作完成、等待锁、等待其他线程的通知等时,线程会进入阻塞状态。在这个状态下,线程暂停执行,并且不消耗CPU资源。
-
等待(Waiting):
- 当线程调用
Object.wait()
、Thread.join()
或者LockSupport.park()
等方法时,线程会进入等待状态。在这个状态下,线程暂停执行,直到其他线程唤醒它。
- 当线程调用
-
超时等待(Timed Waiting):
- 与等待状态类似,不过在等待一段时间后会自动恢复。例如,线程调用
Thread.sleep()
、Object.wait(long timeout)
、Thread.join(long millis)
或者LockSupport.parkNanos()
方法时,会进入超时等待状态。
- 与等待状态类似,不过在等待一段时间后会自动恢复。例如,线程调用
-
终止(Terminated):
- 线程执行完任务后或者因为异常(线程调用stop(), destory())而结束时,线程处于终止状态。在这个状态下,线程不再执行任务,并且它的生命周期已经结束。
并发共享模型
在线程并发共享时,可能会遇到以下几类问题:
-
竞态条件(Race Condition):
- 竞态条件是指多个线程同时访问共享资源,并试图对资源进行修改,导致最终的结果依赖于线程执行的顺序或时机。如果没有正确的同步机制,竞态条件可能会导致不确定的结果或者程序的错误行为。
-
数据竞争(Data Race):
- 数据竞争是一种特殊的竞态条件,指的是多个线程同时访问共享数据,并且至少有一个线程试图对数据进行写操作。如果没有正确的同步机制保护共享数据,可能会导致数据的不一致或者不正确的结果。
-
死锁(Deadlock):
- 死锁是指两个或多个线程相互等待对方释放持有的资源而无法继续执行的情况。当多个线程之间存在循环等待资源的关系,并且都持有对方需要的资源时,就会发生死锁。
-
活锁(Livelock):
- 活锁是一种特殊的死锁情况,其中线程不断重试某个操作,但每次重试都因为对方的操作而失败,导致线程无法继续执行。
-
资源竞争(Resource Contention):
- 资源竞争是指多个线程在竞争有限的资源时发生的问题。例如,多个线程同时竞争一个锁、一个缓存、一个数据库连接等,可能会导致资源的争用和性能下降。
这些问题在多线程编程中是比较常见的,正确地处理并发共享可以避免这些问题的发生。常用的处理方法包括使用锁(如 synchronized、ReentrantLock)、使用线程安全的数据结构、避免共享状态等。
临界区(Critical Section)
临界区指在多线程编程中,访问共享资源或共享数据的那部分代码区域(对共享资源又有读又有写的区域)。在临界区内,只有一个线程可以执行,其他线程必须等待该线程退出临界区才能进入。
临界区的目的是确保多个线程不会同时修改共享资源,从而避免竞态条件和数据竞争等并发问题。通过合理地设计和保护临界区,可以确保多线程程序的正确性和稳定性。
在Java中,常见的保护临界区的方法包括使用synchronized关键字或者显式地使用锁对象(如ReentrantLock)
synchronized
synchronized
是 Java 中用于实现同步的关键字,它可以用来修饰方法或代码块,确保多个线程对共享资源的访问是安全的。
synchronized
的作用是保护共享资源,防止多个线程同时访问共享资源而导致数据不一致或者不确定的结果。在 Java 中,每个对象都可以作为一个锁,并且每个对象都关联着一个监视器(Monitor)。当一个线程试图进入一个被 synchronized
关键字修饰的代码块时,它必须先获取到对象的监视器。这样就确保了同一时刻只有一个线程能够访问共享资源,从而避免了并发访问的问题。具体的过程如下:
-
当一个线程进入一个被
synchronized
修饰的代码块时,虚拟机会首先尝试获取对象的监视器。 -
如果对象的监视器尚未被任何线程占用,那么当前线程将成功获取监视器,并进入临界区(即进入同步块执行相关代码)。
-
如果对象的监视器已经被其他线程占用,那么当前线程将被阻塞,直到其他线程释放了该监视器。
-
当其他线程释放了监视器后,被阻塞的线程将被唤醒,并尝试重新获取监视器
synchronized
修饰类和方法的区别主要体现在锁的对象上:
- 修饰普通方法:
- 当
synchronized
修饰一个普通方法时,锁对象是这个方法所属对象的实例(即this
对象)。 - 这意味着,对于同一个对象的多个线程调用这个方法时,它们会互斥执行,确保了方法内的操作原子性和可见性。
- 如果使用不同的对象实例来调用这个方法,那么这些调用不会互斥,因为每个对象实例都有自己的锁。
- 当
- 修饰静态方法:
- 当
synchronized
修饰一个静态方法时,锁对象是这个方法的类对象(即类的.class
对象或者字节码文件对象)。 - 这意味着,对于同一个类的所有对象,这个静态方法的执行是互斥的。这可以看作是该类的一个全局锁。
- 静态方法的锁对象是独立于任何实例的,因此所有实例共享同一把锁。
- 当
- 同步代码块:
- 在同步代码块中,可以指定一个锁对象,这个锁对象可以是当前实例(
this
)或者类对象(如ClassName.class
)。 - 当线程进入同步代码块时,它会获取指定对象的锁,直到代码块执行完成后释放。这提供了比方法级别更细粒度的同步控制
- 在同步代码块中,可以指定一个锁对象,这个锁对象可以是当前实例(
在多线程环境下,变量的线程安全问题是指多个线程并发访问同一个变量时可能产生的问题,包括竞态条件(Race Condition)、数据竞争(Data Race)、内存可见性问题等。主要有以下几种情况:
竞态条件(Race Condition):
- 当多个线程同时访问同一个变量,并且至少有一个线程进行了写操作时,如果最终的结果依赖于线程执行的顺序或时机,就可能出现竞态条件。
数据竞争(Data Race):
- 数据竞争是竞态条件的一种特殊情况,指的是多个线程同时访问一个共享变量,并且至少有一个线程试图对变量进行写操作。如果没有适当的同步机制保护共享变量,可能会导致数据的不一致或者不正确的结果。
内存可见性问题(Visibility Problem):
- 当一个线程对共享变量进行修改时,该变量的值可能不会立即被其他线程看到,这是因为每个线程有自己的工作内存,变量的修改可能会被延迟写回主内存。如果没有适当的同步机制,其他线程可能会看到过期的或者不正确的值,导致内存可见性问题。
操作的原子性(Atomicity):
- 有些操作需要多个步骤才能完成,例如读取-修改-写入(Read-Modify-Write)操作。如果这些操作不是原子的,即使是简单的操作也可能会受到并发影响,导致不正确的结果。
为了解决变量的线程安全问题,需要采取合适的同步机制来保护共享变量的访问,例如使用 synchronized 关键字、使用锁对象(如 ReentrantLock)、使用线程安全的数据结构(如 ConcurrentHashMap、AtomicInteger 等)、使用 volatile 关键字等。这些方法可以确保在多线程环境中对共享变量的访问是安全的,并避免竞态条件、数据竞争、内存可见性问题等并发问题的发生。
常见的线程安全类包括:
-
ConcurrentHashMap:
- ConcurrentHashMap 是 HashMap 的线程安全版本,它提供了一种高效的并发哈希表实现。它使用分段锁技术来保证线程安全,允许多个线程同时读取并发修改不同的部分。
-
CopyOnWriteArrayList 和 CopyOnWriteArraySet:
- CopyOnWriteArrayList 和 CopyOnWriteArraySet 是 ArrayList 和 HashSet 的线程安全版本,它们在迭代时不需要额外的同步,并且支持并发修改。它们的实现方式是在修改时复制一份新的数组,并在新数组上进行修改,因此对原有数组的读操作不受影响。
-
ConcurrentLinkedQueue 和 ConcurrentLinkedDeque:
- ConcurrentLinkedQueue 和 ConcurrentLinkedDeque 是队列和双端队列的线程安全版本,它们基于链表实现,并使用 CAS(Compare and Swap)操作来实现无锁并发访问。
-
BlockingQueue 接口:
- BlockingQueue 是一个阻塞队列接口,它提供了一系列的阻塞操作,比如 put()、take() 等,可以用于实现生产者-消费者模式。常见的实现类包括 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue 等。
-
AtomicInteger、AtomicLong 等原子类:
- 原子类提供了一系列原子操作,可以保证对变量的操作是原子的,即不可分割的。例如,AtomicInteger 提供了原子的增加、减少、获取和设置等操作,可以用于实现线程安全的计数器。
-
ThreadLocal:
- ThreadLocal 提供了线程局部变量的支持,每个线程都有自己独立的变量副本,互不影响。ThreadLocal 可以用于解决线程安全问题,例如在多线程环境下保存线程安全的对象。
这些线程安全类提供了一种方便和高效的方式来处理多线程并发访问共享资源的问题,可以大大简化多线程编程中的同步和锁管理工作。
Monitor-重量级锁
监视器\管程(Monitor)是一种同步机制,用于控制多个线程对共享资源的访问。每个Java对象都可以作为一个监视器来实现同步,Java中的监视器基于对象的内置锁(也称为互斥锁)实现。
监视器的基本原理是,在多线程环境下,当一个线程进入同步代码块或方法时,它会尝试获取对象的内置锁,如果锁未被其他线程持有,则该线程获取到锁并进入临界区执行代码,其他线程将被阻塞等待。当线程退出临界区时,会释放锁,其他线程才能获取锁并进入临界区执行。
管程是一种在信号量机制上进行改进的并发编程模型
。
由Java 15废弃偏向锁,谈谈Java Synchronized 的锁机制 - Yano_nankai - 博客园 (cnblogs.com)
NO.7 Monitor(管程)是什么意思?Java中Monitor(管程)的介绍_monitor层是什么 java-CSDN博客
JVM的标记字段:
monitor的作用主要是互斥和同步
-
互斥锁(Mutex):
monitor
可以确保在同一时间只有一个线程可以持有锁,从而保证了共享资源的互斥访问。当一个线程持有锁时,其他线程必须等待,直到持有锁的线程释放锁。 -
同步(Synchronization):
monitor
提供了一种机制,允许线程之间进行协调和通信。通过wait()
、notify()
和notifyAll()
方法,线程可以等待某个条件的发生,并在条件满足时被唤醒。这样可以实现线程之间的同步和协作。
Java中的监视器主要通过以下两种方式实现:
-
synchronized 关键字:
- 使用 synchronized 关键字修饰方法或代码块,可以将方法或代码块变成同步方法或同步代码块。在执行同步方法或代码块时,Java虚拟机会自动获取对象的内置锁,并在方法或代码块执行完毕后释放锁。
-
wait()、notify() 和 notifyAll() 方法:
- 这些方法是 Object 类的成员方法,用于在监视器上进行等待和通知操作。wait() 方法用于使当前线程进入等待状态,释放锁并等待其他线程通知;notify() 和 notifyAll() 方法用于通知等待在监视器上的其他线程,唤醒它们继续执行。
static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: getstatic #2 // <- lock引用 (synchronized开始)
3: dup
4: astore_1 // lock引用 -> slot 1
5: monitorenter // 将 lock对象 MarkWord 置为 Monitor 指针
6: getstatic #3 // <- i
9: iconst_1 // 准备常数 1
10: iadd // +1
11: putstatic #3 // -> i
14: aload_1 // <- lock引用
15: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
16: goto 24
19: astore_2 // e -> slot 2
20: aload_1 // <- lock引用
21: monitorexit // 将 lock对象 MarkWord 重置, 唤醒 EntryList
22: aload_2 // <- slot 2 (e)
23: athrow // throw e
24: return
Exception table:
from to target type
6 16 19 any
19 22 19 any
LineNumberTable:
line 8: 0
line 9: 6
line 10: 14
line 11: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
重量级锁一般不会自旋,因为重量级锁适用于高竞争情况下,此时自旋可能会导致线程长时间的忙等待,浪费大量的CPU资源。相反,重量级锁会直接将竞争失败的线程置于阻塞状态,让出CPU资源给其他线程执行,从而避免了忙等待带来的性能损耗。
在Java中,重量级锁的实现通常是基于操作系统提供的互斥量(Mutex),当一个线程无法获取到锁时,会进入阻塞状态,直到锁可用。这种方式可以有效地避免线程长时间的忙等待,并且在高并发情况下能够保证公平性和稳定性。
轻量级锁
轻量级锁是Java中用于实现同步的一种优化技术,旨在减少多线程竞争下的锁开销。它是Java虚拟机对 synchronized 关键字的一种优化实现。
在Java中,synchronized 关键字可以用于实现对共享资源的同步访问。当一个线程进入同步代码块或同步方法时,会尝试获取对象的锁。在竞争不激烈的情况下,锁的获取通常是快速的,但在高并发的情况下,锁的竞争可能会导致性能下降。
Synchronized原理(轻量级锁篇)_轻量级锁 原理-CSDN博客
轻量级锁的设计思想是针对低竞争情况下的优化,通过减少锁的竞争,以提高同步性能。其基本原理如下:
- 当一个线程尝试获取锁时,首先会尝试使用CAS(Compare And Swap)操作来尝试获取对象头中的锁标记(标识为偏向锁或者无锁状态)。
- 如果成功获取锁标记,则该线程获得了轻量级锁,并且在对象头中记录了锁的指针信息,表示该线程是锁的拥有者。
- 如果获取锁失败,则表示有其他线程正在竞争锁,此时会进入自旋等待状态。
- 如果自旋等待一定次数后仍然无法获取锁,或者被其他线程抢占了锁,那么轻量级锁会膨胀为重量级锁,即转为使用操作系统提供的互斥量来实现同步。
轻量级锁的优势在于它对于线程竞争的响应速度更快,因为它不会立即阻塞线程,而是通过自旋等待来尝试获取锁。这在低竞争环境下可以提高性能。但是,在高竞争情况下,轻量级锁的自旋等待可能会消耗过多的CPU资源,因此并不适用于所有情况。
自旋锁
自旋锁(Spin Lock)是一种基于忙等待(Busy Waiting)的锁机制,在多线程环境中用于实现对共享资源的互斥访问。自旋锁不会让线程进入阻塞状态,而是通过循环反复尝试获取锁,直到成功为止。
自旋锁的基本思想是在尝试获取锁时,线程会循环执行一段忙等待的代码,直到锁可用为止。这种方式避免了线程进入阻塞状态和切换上下文的开销,适用于临界区的持有时间较短、锁竞争不激烈的情况。线程的阻塞唤醒需要从用户态切换到内核态,然后内核态切换tcb线程控制块,将线程的状态设置为阻塞,切到另一个线程的内核态,再从内核态进入用户态,这是一个重量级的操作。应该尽量避免不必要的阻塞和唤醒,以提高系统的性能和效率。
- 忙等待:线程在尝试获取锁时会循环执行一段忙等待的代码,不会进入阻塞状态,直到锁可用。
- 无阻塞:自旋锁不会使线程进入阻塞状态,因此不会发生线程上下文的切换,避免了一些线程调度的开销。
- 高并发性:自旋锁适用于临界区的持有时间较短、锁竞争不激烈的情况,可以提高并发性。
但是,自旋锁也有一些缺点:
- 高消耗:自旋锁可能会导致线程长时间的忙等待,消耗大量的CPU资源。
- 优先级反转:在优先级不同的线程竞争同一把锁时,可能会出现优先级反转的情况,导致低优先级线程长时间占用锁,影响高优先级线程的执行。
轻量级锁的 CAS (当且仅当内存位置的值与预期原值相等时,才会将该位置的值更新为新值。)抢锁失败,线程会挂起阻塞。若正在持有锁的线程在很短的时间内释放锁,那么刚刚进入阻塞状态的线程又要重新申请锁资源。
如果线程持有锁的时间不长,则未获取到锁的线程可以不断尝试获取锁,避免线程被挂起阻塞。JDK 1.7 开始,自旋锁默认开启,自旋次数由 JVM 配置决定。
自旋锁重试之后如果抢锁依然失败,同步锁就会升级至重量级锁,锁标志位改为 10。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet
队列中。
在Java中,自旋锁的实现通常是通过 java.util.concurrent.atomic
包中的原子类来实现的,例如 AtomicInteger
、AtomicBoolean
等。另外,Java中的 java.util.concurrent.locks
包中也提供了 ReentrantLock
类,它的 tryLock()
方法就可以用于实现自旋锁。
锁膨胀
锁膨胀是指在多线程环境下,由于竞争激烈或者等待时间过长等原因,一种轻量级锁或者偏向锁升级为重量级锁的过程。重量级锁通常是由操作系统提供的互斥量来实现的,它具有更强的同步能力,但相对于轻量级锁会带来更大的性能开销。
锁膨胀的过程可以分为两个阶段:
-
轻量级锁膨胀:
- 当一个线程尝试获取锁时,如果发现对象已经被其他线程持有了轻量级锁,但该线程无法获取到锁(例如,锁竞争激烈,或者等待时间过长),就会触发轻量级锁的膨胀。此时,Java虚拟机会将轻量级锁膨胀为重量级锁,并使用操作系统提供的互斥量来保证线程的同步访问。
- 当一个线程尝试获取锁时,如果发现对象已经被其他线程持有了轻量级锁,但该线程无法获取到锁(例如,锁竞争激烈,或者等待时间过长),就会触发轻量级锁的膨胀。此时,Java虚拟机会将轻量级锁膨胀为重量级锁,并使用操作系统提供的互斥量来保证线程的同步访问。
-
偏向锁撤销:
- 如果一个线程尝试获取偏向锁时发现对象已经被其他线程持有了轻量级锁或者重量级锁,那么偏向锁会被撤销,对象回到无锁状态。撤销偏向锁的过程称为偏向锁的撤销,它可能会触发锁的膨胀,即从无锁状态直接升级为重量级锁。
锁膨胀的过程是一种性能的折中,它可以在高并发环境下提供更好的同步性能,但也会带来额外的系统开销。因此,在设计多线程程序时,需要根据实际情况选择合适的锁机制,并且避免不必要的锁竞争和等待时间过长的情况,以尽量减少锁膨胀的发生。
偏向锁
偏向锁(Biased Locking)是Java虚拟机为了优化同步操作而引入的一种锁机制。它的主要目标是降低无竞争情况下的同步操作的性能开销。偏向锁适用于只有一个线程访问同步块的情况,也称为“单线程模式”。
偏向锁的基本思想是,在同步代码块第一次被线程访问时,将锁的标记设置为偏向锁,并将线程的ID记录在锁对象的头部。接下来,当这个线程再次进入同步代码块时,无需进行任何同步操作,因为偏向锁已经被此线程获取了。只有在其他线程尝试获取锁时,偏向锁才会升级为轻量级锁或者重量级锁。
偏向锁的优点包括:
- 减少同步操作的开销:在无竞争的情况下,偏向锁能够减少同步操作的开销,因为不需要进行 CAS 操作和线程的阻塞等操作。
- 适应单线程模式:对于只有一个线程访问同步块的情况,偏向锁能够提供良好的性能,因为不需要与其他线程进行竞争。
但是,偏向锁也有一些限制和缺点:
- 线程竞争时的性能开销:一旦有其他线程尝试获取锁,偏向锁会升级为轻量级锁或者进一步从轻量级锁升级为重量级锁,此时会有额外的性能开销。升级为轻量级锁时,虚拟机会在对象头部存储指向锁记录的指针,并尝试使用CAS(Compare And Swap)操作来获取锁。
- 适应多线程模式的能力有限:对于多线程竞争激烈的情况,偏向锁可能不适用,因为频繁的锁升级会带来性能下降。
在Java 6及以后的版本中,默认启用了偏向锁机制,但是可以通过JVM参数关闭偏向锁。通过观察锁对象的头部标记位可以了解锁的状态,例如在偏向锁状态下,锁的标记位是“01”。
在 JDK15 中,偏向锁被默认关闭。在 JDK18 中,更被标记为废弃,并不再允许通过命令行手动开启。废弃偏向锁的主要原因可能是由于在实际应用中,偏向锁并不总是带来性能的提升,而且在多线程竞争激烈的场景下,偏向锁可能会导致性能下降,甚至影响整个应用程序的性能。
![](https://i-blog.csdnimg.cn/blog_migrate/bc11074f179708a96d3076be920e6236.png)
- 如果开启了偏向锁(默认开启),那么对象创建后,markword 值为 0x05 即最后 3 位为 101,这时它的 thread、epoch、age 都为 0
- 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 - XX:BiasedLockingStartupDelay=0 来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,markword 值为 0x01 即最后 3 位为 001,这时它的 hashcode、 age 都为 0,第一次用到 hashcode 时才会赋值
- 添加 VM 参数 -XX:-UseBiasedLocking 禁用偏向锁
调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销
- 轻量级锁会在锁记录中记录 hashCode
- 重量级锁会在 Monitor 中记录 hashCode
当持有偏向锁的线程结束(即线程退出或者被销毁)时,偏向锁会被撤销,锁对象的状态会回到无锁状态,即原始的无锁对象状态
偏向锁的撤销
- 偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的,stop-the-world),到达全局安全点后,持有偏向锁的线程B也被暂停了。
- 检查持有偏向锁的线程B的状态(会遍历当前JVM的所有线程,如果能找到线程B,则说明偏向的线程B还存活着):
5.1 如果线程还存活,则检查线程是否还在执行同步代码块中的代码:
5.1.1 如果是,则把该偏向锁升级为轻量级锁,且原持有偏向锁的线程B继续获得该轻量级锁。
5.2 如果线程未存活,或线程未在执行同步代码块中的代码,则进行校验是否允许重偏向:
5.2.1 如果不允许重偏向,则将Mark Word设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争锁。
5.2.2 如果允许重偏向,设置为匿名偏向锁状态(即线程B释放偏向锁)。当唤醒线程后,进行CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID)。- 唤醒暂停的线程,从安全点继续执行代码。
偏向锁的批量重偏向
偏向锁的重偏向是指当一个偏向锁对象已经被多个线程竞争获取时,如果某个线程再次尝试获取该锁时,JVM会检查该锁对象是否符合重偏向的条件。如果符合条件,就会对该锁对象进行重偏向,重新将该锁对象设置为偏向锁,并且将当前线程的线程ID记录在锁对象的头部。
重偏向的条件包括:
- 当前偏向锁对象处于可偏向状态(biasable)。
- 当前锁对象曾经被偏向过,但是已经不再是偏向锁状态(即已经被多个线程竞争过)。
- 当前锁对象的线程竞争获取锁的次数达到一定的阈值,通常为20次。
重偏向的目的是尽量减少锁的竞争,将锁的拥有权重新交给最近访问过的线程,从而提高程序的性能。(偏向锁升级为轻量锁会有一定的开销)
需要注意的是,重偏向不会立即触发,而是在偏向锁被多个线程竞争后,经过一定次数的重偏向阈值后才会进行重偏向操作。
偏向锁的批量撤销
偏向锁的批量撤销(Bulk Revocation)是Java虚拟机为了解决偏向锁失效时的性能问题而引入的一种优化措施。当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的001
偏向锁的批量撤销一般会在以下情况下触发:
-
全局安全点(Safepoint):JVM在全局安全点(如垃圾回收时)会触发偏向锁的批量撤销操作,因为此时所有线程都处于安全点,可以确保不会有线程持有锁。
-
阈值条件:JVM会根据一定的撤销阈值来触发偏向锁的批量撤销操作。例如,当有一定数量(40 )的线程尝试获取某个偏向锁时,就会触发批量撤销操作。
批量撤销操作会将所有处于偏向状态的对象的偏向锁撤销,将它们的状态重置为无锁状态,从而避免了单个对象的偏向撤销操作带来的性能开销。这样可以在一定程度上提高程序的性能。
需要注意的是,偏向锁的批量撤销操作可能会带来一定的性能开销,特别是在全局安全点时可能会导致应用程序的停顿。因此,JVM会根据具体的情况和配置来决定是否触发批量撤销操作,以平衡性能和响应性能。
锁消除和锁粗化
锁消除是编译器或运行时环境在编译或运行时自动优化代码的一种技术,它通过分析代码并判断某些同步操作是不必要的,从而将其消除以提高性能。
锁消除通常发生在以下情况下:
-
逃逸分析:编译器对代码进行逃逸分析,如果发现某个对象只在当前线程内部使用,并且不会被传递给其他线程,那么对该对象的同步操作就是不必要的。这时编译器会进行锁消除。
-
循环不变量上提:如果在循环中存在同步代码块,但是循环不变量分析表明同步代码块内部的变量与循环无关,那么编译器也会尝试将同步操作移出循环,从而实现锁消除。
-
字符串同步:如果一个字符串是常量且不可能被其他线程修改,那么对该字符串的同步操作也是不必要的,编译器会进行锁消除。
-
等同对象:如果一个对象有多个引用,并且其中一个引用已经被加锁,但其他引用没有被加锁,那么对其他引用的同步操作也是不必要的,编译器也会进行锁消除。
锁消除能够在一定程度上提高程序的性能,减少不必要的同步操作带来的开销。但是,需要注意的是,锁消除并不是适用于所有情况的,有时候编译器的判断可能会出错,导致锁消除带来的优化效果并不明显。因此,在进行性能优化时,需要结合实际情况进行评估和测试。
锁粗化(Lock Coarsening)是编译器或运行时环境的一种优化技术,它通过将多个连续的同步操作合并为一个大的同步操作来减少同步操作的次数,从而提高性能。
在代码中,如果存在多个连续的同步操作,并且它们对同一个锁对象进行操作,那么编译器或运行时环境可能会将这些连续的同步操作合并成一个大的同步操作。这样做的目的是减少同步操作的粒度,从而降低同步操作的开销,并提高程序的性能。
锁粗化通常发生在以下情况下:
-
循环中的同步操作:如果在循环中存在多个连续的同步操作,而且这些同步操作对同一个锁对象进行操作,那么编译器可能会将这些同步操作合并成一个大的同步操作,从而减少同步操作的次数。
public class Counter { private int count; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } } Counter counter = new Counter(); // 在循环中进行多次计数器增加操作 for (int i = 0; i < 1000; i++) { counter.increment(); }
在这个例子中,循环中的每次计数器增加操作都涉及到对 Counter 对象的同步操作。如果循环执行次数较多,那么同步操作的开销可能会比较大。在这种情况下,编译器或者运行时环境可能会对连续的同步操作进行锁粗化优化,将多次的增加操作合并成一个大的同步操作,以减少同步操作的次数,提高性能。
-
连续的方法调用中的同步操作:如果在连续的方法调用中存在多个同步操作,而且这些同步操作对同一个锁对象进行操作,那么编译器也可能会将这些同步操作合并成一个大的同步操作。
锁粗化能够在一定程度上提高程序的性能,减少同步操作带来的开销。但是,需要注意的是,过度的锁粗化可能会导致锁的持有时间过长,从而影响程序的并发性能。
线程的活跃性
线程的活跃性指的是线程在运行过程中能够按照预期的方式执行,并能够在合理的时间内完成所需的任务。活跃性是多线程编程中的一个重要概念,通常包括以下几个方面:
-
死锁(Deadlock):死锁是指线程之间相互等待对方持有的资源而无法继续执行的情况。解决死锁问题是保证线程活跃性的关键之一。在死锁状态下,所有线程或进程都在等待某个资源被释放,但是这个资源永远无法被释放,因为释放该资源需要其他线程或进程持有的资源,从而形成了一个循环等待的闭环。
-
活锁(Livelock):活锁是指线程之间的一种相互作用,其中一个线程响应另一个线程的动作,但其动作不会导致任何线程取得进展。在活锁中,线程不断地修改自己的状态以响应其他线程的动作,但最终不能使任务得以推进。活锁通常是由于线程之间相互谦让导致的。
-
饥饿(Starvation):饥饿是指一个或多个线程长时间无法获得所需的资源,导致无法执行的情况。通常发生在低优先级线程长时间被高优先级线程抢占资源的情况下。
保证线程活跃性是多线程编程中的重要目标之一。为了解决活跃性问题,可以采取以下一些措施:
- 避免死锁,使用合适的同步机制来避免线程之间的相互等待。
- 避免活锁,设计线程交互的算法要避免产生循环的等待条件。
- 避免饥饿,合理分配资源,避免长时间占用共享资源,以确保所有线程都有机会获得所需的资源。
死锁的产生通常涉及以下几个因素:互斥,持有并等待,非抢占,循环等待
- 互斥条件:资源每次只能被一个线程或进程使用,其他线程或进程需要等待该资源释放。
- 持有和等待条件:一个线程或进程可以持有一个资源并等待其他资源,同时等待其他资源时,仍然保持对已拥有资源的持有。
- 不可抢占条件:资源不能被其他线程或进程抢占,只能由持有资源的线程或进程主动释放。
- 循环等待条件:存在一个资源的循环等待链,每个线程或进程都在等待下一个线程或进程所持有的资源。
死锁的产生可能是由于程序设计错误、资源分配不当、线程间通信不当等原因导致的。要避免死锁的发生,可以采取以下一些方法:检测死锁可以使用 jconsole工具,或者使用jps 定位进程 id,再用jstack 定位死锁
- 避免使用多个锁或资源时的循环等待情况。
- 尽量减少锁的持有时间,以减少死锁发生的可能性。
- 使用资源分配策略,如银行家算法等,来避免死锁的发生。
- 使用超时机制,当线程等待资源的时间超过一定阈值时,放弃等待,释放已持有的资源。
- 使用死锁检测和恢复机制,及时发现死锁并采取相应的措施来恢复正常运行。
解决活锁问题,可以采取以下一些方法:
- 引入随机性:在决定如何响应其他线程的动作时,引入一定的随机性,使得线程不会总是做出相同的反应。
- 限制重试次数:限制线程重试的次数,当达到一定的重试次数后,采取其他策略。
- 协调行动:设计线程之间的交互方式,使得线程可以相互协调,避免彼此不断地重复相似的操作。
- 回退策略:当检测到活锁时,采取回退策略,让线程暂停一段时间后再重新尝试。
饥饿可能发生在以下情况下:
-
低优先级线程被高优先级线程抢占资源:如果一个低优先级的线程需要的资源一直被高优先级线程占用,那么低优先级线程可能长时间无法获取资源,导致饥饿。
-
优先级反转:在某些情况下,低优先级线程可能会持有某个高优先级线程需要的资源,导致高优先级线程长时间等待,无法执行,从而发生饥饿。这种情况通常发生在使用同步机制时,比如使用锁时,低优先级线程持有锁而高优先级线程无法获取锁。
-
资源竞争:多个线程竞争某些共享资源,但是由于竞争不公平或者资源分配不合理,导致某些线程长时间无法获取所需资源,从而发生饥饿。
为了避免饥饿的发生,可以采取以下一些方法:
- 合理分配资源:合理设计线程之间的资源分配,避免某些线程长时间占用资源而导致其他线程无法获取资源。
- 优先级设置:合理设置线程的优先级,确保高优先级的线程能够及时获得所需的资源。
- 避免优先级反转:使用适当的同步机制来避免优先级反转问题,比如使用公平锁。
- 避免资源竞争:尽量减少线程之间的资源竞争,使用合适的并发控制手段来避免竞争条件的发生。
ReentrantLock
ReentrantLock
是 Java 中用于实现可重入锁的一种机制,它提供了与 synchronized
关键字类似的同步功能,但具有更加灵活和强大的功能。
ReentrantLock
主要提供了以下几个特点:
-
可重入性:与
synchronized
关键字类似,ReentrantLock
支持线程对同一个锁的重入,同一个线程可以多次获得同一个锁,而不会导致死锁。 -
公平性选择:
ReentrantLock
提供了公平锁和非公平锁两种模式。在公平锁模式下,线程会按照先后顺序获取锁;而在非公平锁模式下,线程有可能插队获取锁,提高了整体的吞吐量。虽然公平锁模式可以避免线程饥饿的情况,但是在高并发的场景下,可能会导致线程竞争激烈,降低了程序的并发性能。 -
条件变量支持:
ReentrantLock
提供了与条件变量相关的功能,可以通过newCondition()
方法创建条件变量,用于实现更加复杂的线程间协作。条件变量通常与锁配合使用,用于在多线程间进行等待和通知的机制。条件变量通常用于以下场景:线程间的等待/通知机制:
-
某些线程需要等待某个条件满足时才能继续执行,而另外的线程在满足条件时通知等待的线程继续执行。
-
生产者-消费者模式:生产者线程在某个条件满足时生产数据,消费者线程在满足条件时消费数据,通过条件变量进行线程间的协调。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionVariableExample {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private boolean conditionMet = false;
public void waitForCondition() throws InterruptedException {
lock.lock();
try {
while (!conditionMet) {
condition.await(); // 等待条件满足
}
// 条件满足,继续执行
} finally {
lock.unlock();
}
}
public void signalCondition() {
lock.lock();
try {
conditionMet = true; // 设置条件为满足
condition.signal(); // 唤醒等待的线程
} finally {
lock.unlock();
}
}
}
通过调用 lock()
方法获取锁,在临界区执行相关代码,最后通过调用 unlock()
方法释放锁。确保在使用 ReentrantLock
时,始终在 try-finally
块中释放锁,以防止锁无法释放导致死锁的发生。
ConditionVariableExample
类包含了一个 ReentrantLock
和一个关联的条件变量 Condition
。waitForCondition()
方法通过调用 await()
方法等待条件满足,而 signalCondition()
方法通过调用 signal()
方法来通知条件变量满足。
超时获取锁:
tryLock(long time, TimeUnit unit)
方法允许线程在指定的时间内尝试获取锁,如果在指定的时间内未能获取到锁,则返回 false,表示获取锁失败;如果在指定的时间内成功获取到锁,则返回 true。该方法允许线程避免长时间等待,提高了程序的响应性。
可中断获取锁: lockInterruptibly()
方法允许线程在获取锁的过程中响应外部中断,如果线程在等待获取锁的过程中被中断,则会抛出 InterruptedException
异常,线程可以捕获该异常做相应的处理。使用synchronized如果获得锁失败,会阻塞。
可轮询获取锁: tryLock()
方法允许线程尝试获取锁,如果锁是可用的,则立即返回 true;如果锁不可用,则立即返回 false,表示获取锁失败。与 tryLock(long time, TimeUnit unit)
方法不同的是,tryLock()
方法不会等待,它是非阻塞的。
JMM-JAVA内存模型
Java 内存模型是 Java 平台定义的一种抽象概念,用于规范 Java 程序中多线程并发访问共享内存的行为。它定义了线程如何与内存交互,以及在不同线程之间如何可见共享变量的值。它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
Java 内存模型的主要目标是定义 Java 虚拟机如何通过内存和缓存来处理多线程并发访问共享变量的情况,以确保在不同线程间的数据一致性和可见性。
JMM 定义了以下几个关键概念:
-
主内存(Main Memory):主内存是所有线程共享的内存区域,存储了所有共享变量的值。
-
工作内存(Working Memory):每个线程都有自己的工作内存,用于保存该线程使用到的变量的副本。线程对变量的所有操作都在工作内存中进行,而不是直接在主内存中进行。
-
内存屏障(Memory Barrier):内存屏障是一种同步机制,用于保证特定操作的内存可见性。它可以确保在某个线程的指定位置上的内存操作对其他线程是可见的。volatile的原理就是内存屏障,对 volatile 变量的写指令后会加入写屏障,写屏障保证写屏障前的代码不会重排序,对 volatile 变量的读指令前会加入读屏障,读屏障后的代码不会发生重排序
-
在 JVM 中,主内存和工作内存并没有具体的区域或者内存模块来表示,它们更多地是一种抽象概念,用于描述线程之间如何共享变量的值。
-
原子性(Atomicity):保证指令不会受到线程上下文切换的影响,原子性指的是一个操作是否能够被中断,如果一个操作是原子的,那么它要么完全执行,要么完全不执行,不会出现部分执行的情况。
-
有序性(Ordering):保证指令不会受 cpu 指令并行优化的影响,有序性指的是指令重排序对多线程程序的影响。在 Java 内存模型中,对于单个线程来说,其操作按照程序顺序执行,但是不同线程之间的操作顺序不一定保证一致。
-
可见性:保证指令不会受 cpu 缓存的影响,可见性是指当一个线程修改了共享变量的值后,其他线程能够立即看到这个变化,通常发生在多个线程同时访问共享变量的情况下,由于现代计算机的缓存机制以及处理器的指令重排序等原因,一个线程修改了共享变量的值后,这个变化不一定会立即被其他线程看到,可能会出现缓存不一致的情况,导致其他线程读取到的是过期的值。
-
使用 volatile 关键字(jdk1.5+):volatile 关键字可以保证共享变量的可见性和有序性(防止指令重排序)。当一个变量被声明为 volatile 后,对这个变量的写操作会立即被刷新到主内存,而对这个变量的读操作会直接从主内存中获取,而不是从线程的工作内存中获取。
-
使用 synchronized 关键字:synchronized 关键字不仅可以保证原子性,还可以保证可见性和有序性(synchronized 的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。如果共享变量完全被synchronized保护,此时不需要考虑指令重排序问题)。当一个线程获取了对象的锁后,它会清空工作内存中的共享变量值,从而强制从主内存中重新读取共享变量的值。(System.out.println里面加了synchronized锁)
-
使用并发工具类:Java 并发工具类中提供了一些线程安全的数据结构和同步工具,如 AtomicInteger、ConcurrentHashMap、CountDownLatch 等,它们内部都使用了一些机制来保证共享变量的可见性。
某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁
synchronized和volatile-指令重排序
synchronized 虽然不能禁止指令重排,但也能保证有序性?
这个有序性是相对语义来看的,线程与线程间,每一个 synchronized 块可以看成是一个原子操作,它保证每个时刻只有一个线程执行同步代码,它可以解决上面引述的工作内存和主内存同步延迟现象引发的无序
所以,synchronized 和 volatile 的有序性与可见性是两个角度来看的:
- synchronized 是因为块与块之间看起来是原子操作,块与块之间有序可见
- volatile 是在底层通过内存屏障防止指令重排的,变量前后之间的指令与指令之间有序可见
同时,synchronized 和 volatile 有序性不同也是因为其实现原理不同:
- synchronized 靠操作系统内核互斥锁实现的,相当于 JMM 中的 lock 和 unlock。退出代码块时一定会刷新变量回主内存
- volatile 靠插入内存屏障指令防止其后面的指令跑到它前面去了
总而言之就是, synchronized 块里的非原子操作依旧可能发生指令重排
volatile的原理就是内存屏障,对 volatile 变量的写指令后会加入写屏障,写屏障保证写屏障前的代码不会重排序,对 volatile 变量的读指令前会加入读屏障,读屏障后的代码不会发生重排序。
Java 编译器在生成指令序列时会在 volatile
变量的读写操作前后插入内存屏障指令。内存屏障会确保在其前面的内存操作在其后面的内存操作之前完成,从而保证了内存操作的顺序性。
在读取 volatile
变量时,会插入一个 LoadLoad 屏障,确保当前线程读取到的值是最新的。在写入 volatile
变量时,会插入一个 StoreStore 屏障,确保当前线程的写操作对其他线程是可见的,并且插入一个 StoreLoad 屏障,确保当前线程的写操作不会和后续的读操作重排序。
双重检查锁定(Double-Check Locking)
双重检查锁定(Double-Check Locking)是一种常见的单例模式实现方式,旨在在多线程环境下实现延迟加载(懒汉式)的单例对象,同时保证了线程安全性。该模式通过在获取单例实例时进行两次 null 检查,并在必要时使用同步锁来确保只有一个线程创建实例,从而实现了高效的单例创建。
public class Singleton {
private static volatile Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
解释一下双重检查锁定模式的实现过程:
- 第一次检查:检查单例对象是否已经被实例化,如果没有则进入同步代码块。
- 获取锁:进入同步代码块后,再次检查单例对象是否已经被实例化,如果没有则创建实例。
- 释放锁:创建实例后,释放同步代码块中的锁。
这种方式的优点在于,在单例对象被实例化之后,后续获取实例时无需再进入同步代码块,提高了性能。同时,通过双重检查的方式,保证了在多线程环境下也能正确地创建单例对象,并且避免了每次获取实例都需要进行同步的性能损耗。
一些问题
-
为什么加 final?
- 将类声明为 final 可以防止该类被继承,从而确保单例类不会被子类化。这样可以避免在子类中创建新的实例,保持单例的唯一性。
-
如果实现了序列化接口, 还要做什么来防止反序列化破坏单例?
- 如果一个类实现了 Serializable 接口,那么它在被反序列化时会创建一个新的对象,而不是使用单例对象。为了防止这种情况,可以在单例类中实现
readResolve()
方法,并在该方法中返回单例对象。这样反序列化时就会使用readResolve()
方法返回的单例对象。
- 如果一个类实现了 Serializable 接口,那么它在被反序列化时会创建一个新的对象,而不是使用单例对象。为了防止这种情况,可以在单例类中实现
-
为什么设置为私有? 是否能防止反射创建新的实例?
- 将构造方法设为私有的确可以防止外部类直接通过 new 关键字来实例化对象,但不能防止通过反射机制创建新的实例。为了防止反射创建新的实例,可以在构造方法中添加逻辑,如果已经存在实例则抛出异常,确保只有一个实例存在。
-
这样初始化是否能保证单例对象创建时的线程安全?
- 是的,由于静态变量初始化是在类加载时进行的,并且类加载过程是线程安全的,因此可以保证单例对象在类加载时被创建,是线程安全的。
-
为什么提供静态方法而不是直接将 INSTANCE 设置为 public?
- 提供静态方法
getInstance()
而不是直接将INSTANCE
设置为 public,主要是为了提供更好的灵活性和控制。通过静态方法可以在需要时延迟加载单例对象,可以添加额外的逻辑,比如懒加载、双重检查锁定等,而直接将INSTANCE
设置为 public 则无法做到这一点。此外,静态方法可以更好地封装单例对象的获取逻辑,对外隐藏实现细节,提供更好的封装性。
- 提供静态方法
-
枚举单例是如何限制实例个数的?
- 枚举类型在 Java 中是一种特殊的类型,它的实例数量是固定的,且在整个应用程序生命周期内只会被创建一次。因此,枚举单例通过枚举类型的特性来限制实例个数,保证了只有一个实例存在。
-
枚举单例在创建时是否有并发问题?
- 枚举单例在创建时不会存在并发问题,因为枚举类型的实例是在类加载阶段被创建的,而类加载过程是线程安全的。因此,枚举单例的创建是线程安全的,不会出现并发问题。
-
枚举单例能否被反射破坏单例?
- 枚举单例不能被反射破坏单例。枚举类型在 Java 中是一种特殊的类型,JVM 在加载枚举类型时会自动阻止反射调用构造方法创建新的实例。
- 反射在通过newInstance创建对象时,会检查该类是否ENUM修饰,如果是则抛出异常,反射失败。
if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");
-
枚举单例能否被反序列化破坏单例?
- 枚举单例不能被反序列化破坏单例。枚举类型在序列化和反序列化过程中会被特殊处理,JVM 会保证只有一个实例被序列化和反序列化,因此不会破坏单例。
-
枚举单例属于懒汉式还是饿汉式?
- 枚举单例既不属于懒汉式也不属于饿汉式,它是一种更加优雅和安全的单例实现方式。枚举类型在类加载时会立即初始化实例,因此可以看作是一种饿汉式单例。但是,枚举类型的实例是在类加载阶段被创建的,而不是在首次调用
getInstance()
方法时才创建,因此不会出现懒汉式的延迟加载特性。
- 枚举单例既不属于懒汉式也不属于饿汉式,它是一种更加优雅和安全的单例实现方式。枚举类型在类加载时会立即初始化实例,因此可以看作是一种饿汉式单例。但是,枚举类型的实例是在类加载阶段被创建的,而不是在首次调用
-
枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做?
- 枚举单例可以通过枚举类型的构造方法来实现单例创建时的初始化逻辑。枚举类型可以定义带参数的构造方法,从而可以在创建枚举实例时传入初始化参数,并在构造方法中执行初始化逻辑。
-
解释为什么要加 volatile?
- 将 INSTANCE 字段声明为 volatile 可以确保多个线程之间的可见性。在 Java 中,对于 volatile 字段的读写操作会立即被其他线程看到,而不会出现线程间的数据不一致的情况。在双重检查锁定模式中,如果没有使用 volatile 修饰 INSTANCE 字段,可能会导致某个线程在获取到锁之后看到的 INSTANCE 对象仍然是 null(指令重排序,先执行赋值语句,后执行创建对象),从而重复创建实例。
-
为什么要在这里加为空判断?
- 在双重检查锁定模式中,第一次判断 INSTANCE 是否为 null 是为了避免多个线程同时通过了第一次判断后,导致多次创建实例。而在 synchronized 块内部再次判断 INSTANCE 是否为 null 是为了在多线程环境下避免重复创建实例。因为在 synchronized 块内部,只有一个线程能够获取锁,其他线程需要等待锁释放后才能进入 synchronized 块。在这种情况下,如果不再次判断 INSTANCE 是否为 null,那么当多个线程同时通过 synchronized 块内部的判断时,会导致多次创建实例,破坏了单例模式的要求。
-
为什么还要在这里加为空判断, 之前不是判断过了吗?
- 是的,之前的判断确实是为了避免多个线程同时通过第一次判断后重复创建实例。但是,由于在多线程环境下,多个线程可能同时通过了第一次判断,并且其中一个线程创建了实例后释放了锁,此时其他线程可以获取锁并进入 synchronized 块。如果不再次判断 INSTANCE 是否为 null,那么在这种情况下,其他线程仍然会创建实例,从而违反了单例模式的要求。因此,在 synchronized 块内部还需要再次判断 INSTANCE 是否为 null。
-
属于懒汉式还是饿汉式?
- 这种方式属于懒汉式单例模式。在懒汉式单例模式中,单例对象在第一次使用时才会被创建。 LazyHolder 类的加载是在调用
getInstance()
方法时才会发生,因此属于懒汉式。
- 这种方式属于懒汉式单例模式。在懒汉式单例模式中,单例对象在第一次使用时才会被创建。 LazyHolder 类的加载是在调用
-
在创建时是否有并发问题?
- 这种方式在创建时没有并发问题。由于 Java 类加载机制保证了类的初始化是线程安全的,LazyHolder 类的加载在第一次调用
getInstance()
方法时才会发生,由于静态变量初始化是在类加载时进行的,并且类加载过程是线程安全的,因此可以保证单例对象在类加载时被创建,是线程安全的。
- 这种方式在创建时没有并发问题。由于 Java 类加载机制保证了类的初始化是线程安全的,LazyHolder 类的加载在第一次调用
CAS
CAS(Compare And Swap)是一种用于实现多线程同步的原子操作,通常用于解决并发环境下的共享数据的更新问题。CAS 操作包含三个参数:内存位置(V)、预期原值(A)和新值(B)。当且仅当内存位置的值与预期原值相等时,才会将该位置的值更新为新值。
其实 CAS 的底层是 lock cmpxchg 指令( X86 架构), 在多核状态下,某个核执行到带 lock 的指令时, CPU 会让总线锁住,当这个核把此指令执行完毕,再 开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子 的。
CAS 操作通常包含以下步骤:
-
读取内存位置的值(V):首先,线程从内存中读取某个共享变量的当前值 V。
-
比较预期原值(A):线程比较读取到的值 V 是否与预期原值 A 相等。
-
如果相等,则更新内存位置的值:如果相等,则将内存位置的值更新为新值 B。否则,不执行任何操作。
-
返回操作结果:无论更新操作是否成功,CAS 都会返回操作之前内存位置的当前值 V。
CAS 操作通常以原子方式执行,因此它是线程安全的。它允许多个线程同时更新相同的内存位置,但最终只有一个线程的更新操作会成功,其他线程的更新操作会失败。
CAS 操作常用于实现非阻塞算法和并发数据结构,例如 AtomicInteger、AtomicBoolean 和 AtomicReference 等原子类都是基于 CAS 操作实现的。CAS 操作的性能通常比锁机制更好,因为它避免了线程的阻塞和内核态的切换,但是它也有一些局限性,比如 ABA 问题和循环时间长开销大等。
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。注意 volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能保证原子性CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
![](https://i-blog.csdnimg.cn/blog_migrate/14fd049e1263207356debdb1a5f30814.png)
原子整数
原子整数是一种可以保证原子性操作的整数类型。在多线程环境下,如果多个线程同时对同一个整数进行读取、修改、写入等操作,可能会出现竞态条件(Race Condition),导致数据不一致或者丢失更新等问题。原子整数就是为了解决这类问题而设计的。
在 Java 中,java.util.concurrent.atomic
包提供了一系列原子类,其中包括 AtomicInteger
、AtomicLong
等原子整数类型。这些原子类提供了一系列原子性操作,比如 get()
获取当前值,set(int newValue)
设置新值,以及一系列的原子更新操作,比如 incrementAndGet()
、decrementAndGet()
、compareAndSet(int expect, int update)
等。这些原子操作都是以原子方式进行的,即在多线程环境下可以保证线程安全。
原子整数的实现通常依赖于底层的硬件原子性操作指令(在多核状态下,某个核执行到带 lock 的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。),比如 CAS(Compare and Swap)指令。CAS 操作是一种乐观锁的实现方式,它可以保证在没有其他线程干扰的情况下,对共享变量进行原子性操作。如果发现变量已经被其他线程修改过,则会放弃更新,重试操作。
因此,使用原子整数可以在多线程环境下安全地进行对整数的操作,而不需要显式加锁。这在并发编程中非常有用,可以提高程序的性能和可维护性。
原子引用
原子引用(Atomic Reference)是一种特殊的原子类型,用于在多线程环境下对引用类型的变量进行原子性操作。在Java中,java.util.concurrent.atomic
包提供了AtomicReference
类来实现原子引用。
原子引用主要用于解决多线程并发访问共享引用对象时可能出现的竞态条件问题。它提供了一系列原子性的操作,包括获取当前引用对象、设置新的引用对象以及比较并设置等操作。这些操作都是以原子方式进行的,保证在多线程环境下的线程安全性。
例如,AtomicReference
类中的compareAndSet
方法可以用来实现乐观锁机制。它会先比较当前引用对象是否与预期的引用对象相同,如果相同则更新为新的引用对象,这个操作是原子性的,可以保证在没有其他线程干扰的情况下进行更新操作。
使用原子引用可以避免显式地使用锁来保护共享对象,从而提高程序的并发性能和可维护性。然而,需要注意的是,原子引用虽然可以保证引用对象的更新操作是原子性的,但并不能解决引用对象本身的线程安全问题。因此,在使用原子引用时,仍然需要注意引用对象的线程安全性。
ABA 问题
ABA 问题是在并发编程中常见的一种问题,指的是在执行比较并替换操作时,如果被比较的值在比较前后发生了变化,但又回到了原始值,可能会导致比较并替换操作错误地认为值未被修改。这种情况可能会引发一些潜在的问题,例如数据不一致或者程序逻辑错误。
举个例子来说明 ABA 问题:
假设有一个共享变量的初始值为 A,线程 T1 将其修改为 B,然后再修改回 A,而线程 T2 正好在此期间执行了比较并替换操作,由于 T2 比较的值在比较前后都是 A,因此错误地认为值未被修改。
为了解决 ABA 问题,通常采用以下两种方法之一:
-
版本号或标记: 在执行比较并替换操作时,引入一个版本号或者标记,每次修改值时都对版本号进行递增或者标记进行修改。这样,在执行比较并替换操作时,除了比较值外,还需要比较版本号或者标记,只有在值和版本号或者标记都匹配时才进行替换操作。
AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如: A - > B - > A - > C ,通过 AtomicStampedReference ,我们可以知道,引用变量中途被更改了几次。但是有时候,并不关心引用变量更改了几次,只是单纯的关心 是否更改过 ,所以就有了AtomicMarkableReference -
使用 CAS(Compare And Swap)的原子操作: CAS 操作是一种乐观锁的实现方式,它可以确保在没有其他线程干扰的情况下,对共享变量进行原子性操作。在 CAS 操作中,除了比较值外,还需要比较变量的修改前后是否一致,只有在变量的修改前后一致的情况下才进行替换操作。
原子数组
原子数组是一种支持原子操作的数据结构,它允许在多线程环境下对数组元素进行原子性操作,以确保线程安全性。
在 Java 中,java.util.concurrent.atomic
包提供了 AtomicIntegerArray
、AtomicLongArray
和 AtomicReferenceArray
等类来实现原子数组。
以下是这些原子数组类的一些常用方法:
- get(int index):获取数组中指定索引处的元素值。
- set(int index, int newValue):设置数组中指定索引处的元素值为指定的新值。
- compareAndSet(int index, int expect, int update):比较数组中指定索引处的元素值与期望值,如果相等则更新为新值。
- incrementAndGet(int index):将数组中指定索引处的元素值增加 1,并返回增加后的值。
- decrementAndGet(int index):将数组中指定索引处的元素值减少 1,并返回减少后的值。
- addAndGet(int index, int delta):将数组中指定索引处的元素值增加指定的增量,并返回增加后的值。
原子更新器
原子更新器(Atomic Updater)是 Java 并发包中提供的一组工具,用于对某个对象的某个字段进行原子更新操作。它们可以在不使用锁的情况下实现对共享变量的原子性更新,从而提高并发性能。
在 Java 中,原子更新器主要包括以下几种类型:
- AtomicIntegerFieldUpdater<T>:用于原子地更新某个对象的
int
类型字段。 - AtomicLongFieldUpdater<T>:用于原子地更新某个对象的
long
类型字段。 - AtomicStampedReference<T>:用于原子地更新某个对象的引用类型字段,并且可以附加一个版本号(stamp)来解决 ABA 问题。
- AtomicReferenceFieldUpdater<T,V>:用于原子地更新某个对象的引用类型字段。
这些原子更新器提供了一系列原子性操作方法,比如 compareAndSet
、getAndSet
、getAndIncrement
等,可以在多线程环境下对共享变量进行安全地读取和更新操作,而不需要额外的同步措施。
原子累加器
原子累加器(Atomic Accumulator)是一种原子性操作的数据结构,用于在多线程环境中对一个数值进行累加操作,保证操作的原子性。在 Java 中,java.util.concurrent.atomic
包提供了 LongAccumulator
和 DoubleAccumulator
类来实现原子累加器。
这些原子累加器类提供了一种更加灵活的方式来执行累加操作,允许在累加过程中进行自定义的函数计算。例如,可以定义一个累加器来执行加法、减法、乘法等不同的累加操作,并且可以提供一个初始值。
以下是 LongAccumulator
类的一些常用方法:
- accumulate(long x):原子地将指定值与当前值进行累加。
- get():获取当前的累加结果。
- getThenReset():获取当前的累加结果,并重置累加器的值为初始值。
- getAndAccumulate(long x, LongBinaryOperator accumulatorFunction):原子地将指定值与当前值进行累加,并返回累加之前的值。
- reset():重置累加器的值为初始值。
使用原子累加器可以避免使用锁来保护共享的累加变量,从而提高程序的性能和并发能力。它们适用于需要对某个变量进行频繁更新的场景,例如统计计数、累加求和等。
LongAccumulator
性能提升的原因很简单,就是在有竞争时,设置多个累加单元,Therad-0
累加
Cell[0]
,而
Thread-1
累加 Cell[1]... 最后将结果汇总。这样它们在累加时操作的不同的
Cell
变量,因此减少了
CAS
重试失败,从而提高性能
线程池
自定义一个线程池:
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashSet;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class TestPool {
public static void main(String[] args) {
ThreadPool threadPool = new ThreadPool(10, 3, 5000L, TimeUnit.MILLISECONDS);
for (int i = 0; i < 8; i++) {
// int j = i;
threadPool.execute(() -> {
// System.out.println(j);
});
}
}
}
class ThreadPool {
//任务队列
private BlockingQueue<Runnable> taskQueue;
//最大线程数
private int threadSize;
//线程集合
private HashSet<Worker> workers = new HashSet<Worker>();
//任务超时时间
private long timeOut;
private TimeUnit timeUnit;
public ThreadPool(int capacity, int threadSize, long timeOut, TimeUnit timeUnit) {
this.taskQueue = new BlockingQueue<>(capacity);
this.threadSize = threadSize;
this.timeOut = timeOut;
this.timeUnit = timeUnit;
}
public ThreadPool() {
}
//执行任务
public void execute(Runnable task) {
synchronized (workers) {
if (workers.size() < threadSize) {
System.out.println("新增worker:" + task);
Worker worker = new Worker(task);
workers.add(worker);
worker.start();
} else {
taskQueue.add(task,timeOut,timeUnit);
}
}
}
class Worker extends Thread {
private Runnable runnable;
public Worker(Runnable runnable) {
this.runnable = runnable;
}
@Override
public void run() {
//runnable不为空,执行
//runnable执行完毕,从阻塞队列中获取
while (runnable != null || (runnable = taskQueue.take(timeOut, timeUnit)) != null) {
try {
System.out.println(this + "线程在运行:" + runnable);
runnable.run();
} catch (Exception e) {
e.printStackTrace();
} finally {
runnable = null;
}
}
synchronized (workers) {
System.out.println("线程被移除:" + this);
workers.remove(this);
}
}
}
}
class BlockingQueue<T> {
private Deque<T> queue = new ArrayDeque<>();
private ReentrantLock lock = new ReentrantLock();
//生产者环境变量
private Condition fullWaitSet = lock.newCondition();
//生产者环境变量
private Condition emptyWaitSet = lock.newCondition();
private int capacity;
public BlockingQueue(int capacity) {
this.capacity = capacity;
}
//阻塞获取
public T take(long time, TimeUnit timeUnit) {
lock.lock();
try {
long nanos = timeUnit.toNanos(time);
while (queue!=null ){
try {
//返回的是剩余时间
if (nanos <= 0) {
return null;
}
nanos = emptyWaitSet.awaitNanos(time);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
T t = queue.removeFirst();
fullWaitSet.signal();
System.out.println("获取队列中" + t);
return t;
} finally {
lock.unlock();
}
}
//阻塞添加
public void add(T t,long timeout, TimeUnit timeUnit) {
lock.lock();
try {
long nanos = timeUnit.toNanos(timeout);
while (queue.size() == capacity) {
try {
nanos = fullWaitSet.awaitNanos(nanos);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.addLast(t);
System.out.println(t + "添加到阻塞队列");
emptyWaitSet.signal();
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
return queue.size();
} finally {
lock.unlock();
}
}
}
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- corePoolSize 核心线程数目 (最多保留的线程数)
- maximumPoolSize 最大线程数目
- keepAliveTime 生存时间 - 针对救急线程
- unit 时间单位 - 针对救急线程
- workQueue 阻塞队列
- threadFactory 线程工厂 - 可以为线程创建时起个好名字
- handler 拒绝策略
- 线程池中刚开始没有线程,当一个任务提交给线程池后,线程池会创建一个新线程来执行任务。
- 当线程数达到 corePoolSize 并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue 队列排 队,直到有空闲的线程。
- 如果队列选择了有界队列,那么任务超过了队列大小时,会创建 maximumPoolSize - corePoolSize 数目的线 程来救急。
- 如果线程到达 maximumPoolSize 仍然有新任务这时会执行拒绝策略。拒绝策略 jdk 提供了 4 种实现,其它 著名框架也提供了实现
- 当高峰过去后,超过corePoolSize 的救急线程如果一段时间没有任务做,需要结束节省资源,这个时间由 keepAliveTime 和 unit 来控制。
- AbortPolicy 让调用者抛出 RejectedExecutionException 异常,这是默认策略
- DiscardPolicy 放弃本次任务
- DiscardOldestPolicy 放弃队列中最早的任务,本任务取而代之
- Dubbo 的实现,在抛出 RejectedExecutionException 异常之前会记录日志,并 dump 线程栈信息,方 便定位问题
- Netty 的实现,是创建一个新线程来执行任务
- ActiveMQ 的实现,带超时等待(60s)尝试放入队列,类似我们之前自定义的拒绝策略
- PinPoint 的实现,它使用了一个拒绝策略链,会逐一尝试策略链中每种拒绝策略
Tomcat 线程池
JUC
hashmap死链原理
// 将 table 迁移至 newTable
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
// 1 处
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 2 处
// 将新元素加入 newTable[i], 原 newTable[i] 作为新元素的 next
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
- 究其原因,是因为在多线程环境下使用了非线程安全的 map 集合
- JDK 8 虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味着能 够在多线程环境下能够安全扩容,还会出现其它问题(如扩容丢数据)
JDK 8 ConcurrentHashMap
以上图均来自黑马程序员。