并发简介
我们学到的基本上都是有关顺序编程的知识,即程序中所有事物在任意时刻都只能执行一个步骤。
编程问题中相当大的一部分都可以通过使用顺序编程来解决。然而,对于某些问题,如果能够并发地执行程序中的多个部分,则会变得非常方便。并发编程可以使得程序的处理速度得到极大的提高。但是在得到提高的同时,并发也会带来一些问题,当并行执行的任务彼此开始互相干涉时,时机的并发问题就会接踵而至。
了解并发可以使我们意识到明显正确的程序可能会展现出不正确的行为。
线程
Thread类是线程在Java平台上的实现类,当我们需要启动新线程执行代码时就需要依托于这个类。线程是执行程序的最小单元,可以让程序在多个任务之间并发执行。
Thread构造器只需要一个Runable对象。调用Thread对象的start方法为该线程执行必须的初始化操作,然后调用Runable的run方法,以便在这个新线程中启动任务。注意,由于我们开辟了一条新的线程去执行任务,而不是在原有线程的基础上去顺序执行任务,所以在执行run方法时,main中的代码也将继续执行下去。
如果存在多个线程任务,不同的任务的执行将在线程被换进交换出时混在一起。这种交换是由线程调度器自动控制的。如果存在多处理器,线程调度器将会在这些处理器之间默默地分发线程。
线程的使用
以下是Java中使用线程的基本方法:
继承Thread类:
class MyThread extends Thread {
public void run() {
// 线程执行的代码
}
}
public class Main {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 启动线程
}
}
实现Runnable接口:
class MyRunnable implements Runnable {
public void run() {
// 线程执行的代码
}
}
public class Main {
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start(); // 启动线程
}
}
以上是Java中使用线程的基本方法。无论采用哪种方式,一旦线程被创建,你可以调用start()方法来启动线程,使其开始执行。
总结
事实上,虽然看似是两种方式实现线程,实际上却是一种。因为Thread类的run函数的默认源码实现就是调用了一个包含在Thread对象内部的Runable实现对象。
public class Thread implements Runnable {
private Runnable target;
...
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
@Override
public void run() {
if (target != null) {
target.run();
}
}
}
线程状态
线程的状态会影响任务的执行,一个线程可以具有多种状态,而每种状态都会因为不同的原因而产生。
一个线程可以处于以下四种状态之一
- 初始态:线程被创建完成,但还未被调用start()
- 就绪态:在这种状态下,只要被分配到了CPU时间片,线程就可以运行。
- 阻塞态:线程能够运行,但是由于某种条件阻止了它的运行(sleep、wait、锁争用都会使得线程进入阻塞态)。在阻塞期间,调度器将忽略线程,不会为其分配任何时间片。直到线程重新进入就绪状态。
- 死亡:线程执行完毕。通常是任务结束或者被中断。
进入阻塞态的原因
一个任务进入阻塞状态,可能有如下原因:
- 通过sleep(milliseconds)使任务进入休眠状态,这种情况下,任务会在指定时间内不会运行,一旦时间结束,重新自动进入就绪态。
- 通过wait()使线程挂起。直到线程得到了notify()或notifyAll()消息,线程才会进入就绪态。
- 任务正在等待某个输入输出完成
- 任务试图在某个对象上调用其同步方法,但是为获得锁。
线程相关方法
yield()
yield() 方法是 Thread 类提供的一个静态方法,用于提示当前线程愿意放弃当前的 CPU 执行时间,将 CPU 重新调度给其他线程。调用 yield() 方法的线程将从运行状态转换为就绪状态,然后等待系统重新调度。
yield() 方法的作用是让同优先级的线程有更公平的机会竞争 CPU 资源,但不能保证一定会让出 CPU 资源,因为取决于线程调度器的具体实现。
sleep()
sleep() 方法是 Thread 类的一个静态方法,用于让当前线程暂停执行一段时间,以毫秒为单位。调用 sleep() 方法会导致当前线程进入阻塞状态,让出 CPU 资源,直到指定的时间到达或者被其他线程中断。
join()
join() 方法是 Thread 类的一个实例方法,用于等待指定线程终止。调用 join() 方法会使当前线程进入阻塞状态,直到指定的线程执行完毕或者超时。
在 join() 方法中,如果不带超时参数,则主线程会一直等待指定线程执行完毕;如果带有超时参数,则主线程会等待指定时间后继续执行。如果指定的线程在超时时间内没有执行完毕,则主线程会继续执行后续代码。
线程中断
很多时候我们在线程中的任务会因为一些状况使得我们不希望让其继续执行下去,这时候我们就需要通过某种方式来控制器流程,让其提前结束任务执行。
1.自定义cancel标记
我们可以为我们的自定义Runable实现类提供一个取消标记成员,然后在run方法的执行过程中不断去检查这个标记来判断是否需要提前终止任务。
public class MyRunnable implements Runnable {
private volatile boolean isCancelled = false;
@Override
public void run() {
while (!isCancelled) {
// 执行任务的代码
// 检查是否取消任务
if (isCancelled) {
System.out.println("任务被取消");
return;
}
}
}
// 取消任务的方法
public void cancel() {
isCancelled = true;
}
}
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
// 在需要的时候取消任务
myRunnable.cancel();
2.抛出异常
我们可以通过抛出异常也可以实现线程的中断。同样通过标识来判断抛出异常的时机。当异常抛出后会被线程的异常处理器进行处理。
3.interrupt方法
Thread为我们提供了一个线程中断的方法。通过interrupt方法可实现任务的动态中断。
但事实上,该方法除了sleep()状态以外无法中断线程的任何操作,在线程中存在一个中断标记,我们通过interrupt()方法将其设置为true,而我们的sleep方法会检查该标记,如果为true则抛出中断异常,因此除了sleep以外,线程是不会因为该标记发生中断的(包括锁池中与阻塞时都不会去检查中断标记),但是中断标记为我们动态中断提供了帮助,我们只需要在任务中不断检查线程的中断标记就可以实现由外部线程控制目标线程的动态中断。
public class MyRunnable implements Runnable {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// 执行任务的代码
// 检查中断标记
if (Thread.currentThread().isInterrupted()) {
System.out.println("任务被中断");
return;
}
}
}
}
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
// 在需要的时候中断线程
thread.interrupt();
补充
Thread.interrupted():判断当前线程中断标记,并将其改回false。
Thread.currentThread().isInterrupted():判断当前线程中断标记,不修改中断标记。
线程交互
线程间的交互是指多个线程之间相互协作、共享资源或者信息的过程。常见的线程间交互方式包括共享内存、线程间通信、同步机制等。Java为我们提供了许多线程的交互方法。
wait()、notify()和notifyAll()
wait()方法可以使我们的线程等待某个条件发生变化,而改变这个条件超出当前方法的控制能力。因此线程只能等待。但是我们一定不希望在任务检查这个条件的同时,不断进行空循环,这被称为忙等待,通常是一种不良的CPU周期使用方式,只会不断浪费CPU的性能。因此我们需要wait()在等待外部条件变化前将线程挂起,并且只有notify()和notifyAll()发生时,任务才会被唤醒去检查所产生的变化。
调用sleep()时锁并没有被释放,调用yield()也是这样的情况。但是放一个任务执行到wait()时,线程的执行会被挂起,对象上的锁被释放。这就意味着另一个锁可以获得这个锁。我们可以这么认为,wait()就是在告诉外界:我刚刚已经完成了所能完成的事,因此我要在这里等待,但是在我等待期间并不会阻碍其他线程执行同步方法。
wait()、notify()和notifyAll()有一个特点,它们并不是Thread的一部分,而是作为基类Object的一部分。这看起来很奇怪,因为这三个方法是针对线程的功能现在却作为基类的一部分而实现。事实上因为这些方法是对锁的操作,而这些方法操作的锁也是Object的一部分,所以我们可以将wait()放在同步控制方法中,而不用考虑这个类是实现Thread还是Runable接口。其实这三个方法只能在同步方法和同步块中被调用,而sleep()可以在非同步方法中调用,因为sleep不需要操作锁。如果在非同步方法中调用这三个方法,可以通过编译,毕竟这个是属于Object的一部分方法,但是在运行期间会出现IllegalMonitorStateException异常。
总而言之,当我们需要调用者三个方法我们需要针对锁对象去调用,也就是说我们必须在拿到锁的情况下才能调用这三个方法。
notify()和notifyAll()是对锁对象进行操作的,正常情况,当有多个线程争夺锁时,第一个拿到锁的任务执行完毕之后就会有第二个线程抢到锁进行执行,但是如果是因为wait()进入等待,那么即使它需要的锁被释放它也不会去抢占锁,因为它已经被挂起,需要notify()或notifyAll()进行唤醒,前者唤醒等待该锁的wait等待队列中随机某个任务进入锁池抢占锁,而后者会唤醒所有等待该锁的挂起队列进入锁池抢占锁。
wait()、notify() 和 notifyAll() 方法通常与synchronized关键字一起使用,来实现线程间的协作和同步。这些方法必须在同步代码块或同步方法中调用,并且作用对象是同一个对象锁。在使用时需要特别注意线程间的协作逻辑,以避免产生死锁等问题。
未捕获异常处理器
线程的run方法中抛出的异常未被捕获时,会在线程死亡前被传递给线程的异常处理器,由这个异常处理器来完成异常的处理。
我们可以通过 setUncaughtExceptionHandler() 方法为该线程设置了一个自定义的未捕获异常处理器。当线程抛出未捕获的异常时,该处理器会对异常进行合理的处理。如下所示。
public static void main(String[] args) {
Thread thread = new Thread(() -> {
throw new RuntimeException("Oops! Something went wrong.");
});
// 设置线程的未捕获异常处理器
thread.setUncaughtExceptionHandler((t, e) -> {
System.err.println("Thread " + t.getName() + " threw an uncaught exception: " + e);
});
thread.start();
}
当然我们也可以使用Thread.setDefaultUncaughtExceptionHandler()方法来为所有线程设置一个默认的处理器。这样,当任何线程抛出未捕获的异常时,都会被传递给默认的处理器进行处理。
public class DefaultUncaughtExceptionHandlerExample {
public static void main(String[] args) {
// 设置默认的未捕获异常处理器
Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
System.err.println("Default handler: Thread " + t.getName() + " threw an uncaught exception: " + e);
});
// 创建线程并抛出未捕获异常
Thread thread1 = new Thread(() -> {
throw new RuntimeException("Oops! Something went wrong in thread 1.");
});
thread1.start();
Thread thread2 = new Thread(() -> {
throw new RuntimeException("Oops! Something went wrong in thread 2.");
});
thread2.start();
}
}
在这个示例中,我们通过 Thread.setDefaultUncaughtExceptionHandler() 方法为所有线程设置了一个默认的未捕获异常处理器。然后,我们创建了两个线程并在它们的 run() 方法中抛出了未捕获的异常。由于我们设置了默认的处理器,所以这两个线程抛出的异常会被传递给默认的处理器进行处理。
通过设置默认的未捕获异常处理器,我们可以统一管理所有线程抛出的未捕获异常,从而实现更方便的异常处理。
如果线程没有设置自定义的未捕获异常处理器,并且它的线程组(ThreadGroup)对象有默认的未捕获异常处理器,那么未捕获的异常会被传递给线程组的默认处理器。
每个线程都属于一个线程组,线程组是用来管理线程的,它可以包含多个线程,并且可以有一个默认的未捕获异常处理器。
public class ThreadGroupExample {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("MyThreadGroup") {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.err.println("Thread " + t.getName() + " in group " + getName() + " threw an uncaught exception: " + e);
}
};
Thread thread = new Thread(group, () -> {
throw new RuntimeException("Oops! Something went wrong in thread.");
});
thread.start();
}
}
注意,线程组的异常处理器(uncaughtException() 方法)的优先级高于线程的默认未捕获异常处理器。
在 Java 中,当创建一个新的线程时,如果没有显式地指定线程组,则新线程会继承创建它的线程的线程组作为自己的线程组。这样就形成了线程的层级结构,可以方便地对线程进行管理。
public class ThreadGroupInheritanceExample {
public static void main(String[] args) {
ThreadGroup group = new ThreadGroup("MyThreadGroup") {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.err.println("Thread " + t.getName() + " in group " + getName() + " threw an uncaught exception: " + e);
}
};
Thread thread = new Thread(group, () -> {
throw new RuntimeException("Oops! Something went wrong in thread.");
});
thread.start();
}
}
在这个示例中,我们创建了一个名为 MyThreadGroup 的线程组,并将其设置为线程 thread 的线程组。由于我们没有显式地设置线程组,所以线程 thread 会继承创建它的线程(即主线程)的线程组,即 MyThreadGroup。
因此,当线程 thread 抛出未捕获的异常时,由于它的线程组是 MyThreadGroup,所以异常会被传递给 MyThreadGroup 的默认处理器进行处理。
这种线程组的继承行为可以帮助您更好地组织和管理线程,尤其是在大型应用程序中。
线程组
线程组(Thread Group)是 Java 中用于对线程进行分组和管理的一种机制。线程组可以包含多个线程,并且可以有一个父线程组。线程组提供了一种方便的方式来对线程进行管理和控制,可以对线程组内的线程进行批量操作,比如设置优先级、设置守护线程等。
线程组的主要作用包括:
- 分组管理:线程组可以将多个相关联的线程进行组织和管理,从而方便对这些线程进行操作和监控。
- 层级结构:线程组可以形成层级结构,即线程组可以包含子线程组,而子线程组也可以包含子线程组,以此类推。这样的层级结构有助于对线程进行更细粒度的管理。
- 优先级设置:线程组可以设置一个默认的优先级,当线程组中的线程没有显式设置优先级时,会继承线程组的默认优先级。
- 异常处理:线程组可以设置一个默认的未捕获异常处理器,用于处理线程组中线程抛出的未捕获异常。
- 守护线程设置:线程组可以设置一个默认的守护线程标志,当线程组中的线程没有显式设置守护线程标志时,会继承线程组的默认设置。
- 活动线程监控:线程组可以获取包含在该组中的活动线程的列表。
- 线程池实现: 在实现线程池时,通常会使用线程组来对线程池中的线程进行分组和管理。例如,可以为每个线程池创建一个线程组,并将线程池中的线程加入到对应的线程组中。
线程组在一些场景中可以提供更方便和灵活的线程管理方式,特别是在需要对大量线程进行分组、监控和管理时。
当 JVM 启动时,会自动创建一个名为 “main” 的主线程组(Main Thread Group),所有通过 java 命令启动的线程都会被分配到这个主线程组中。这个主线程组是所有线程的顶级线程组,它的父线程组为 null。
当创建一个新的线程时,如果没有显式地指定线程所属的线程组,则新线程会继承创建它的线程的线程组,如果创建线程的线程也没有显式地指定线程组,它就会继承自主线程组。这样就形成了一个层级的线程组结构。
线程池
线程池是一种管理和复用线程的机制,它可以提供一种有效的方式来处理多个并发任务,减少线程创建和销毁的开销,并且可以更好地控制并发线程数量。以下是一些使用线程池的主要原因:
- 减少线程创建和销毁的开销: 每次创建和销毁线程都会消耗系统资源,因为涉及到与OS的交互。而线程池可以预先创建一定数量的线程,并且在需要时重复利用这些线程,从而避免了频繁地创建和销毁线程,减少了系统开销。
- 控制并发线程数量: 使用线程池可以限制系统中并发线程的数量,从而避免因为过多的线程导致系统资源被耗尽或者性能下降的问题。通过合理地配置线程池的大小,可以有效地控制系统的并发度,提高系统的稳定性和可靠性。
- 提高响应速度: 线程池可以提高任务的响应速度,因为线程池中的线程是预先创建好的,可以立即执行任务,而不需要等待线程创建的时间。
- 统一管理和监控: 线程池提供了一种统一的方式来管理和监控多个线程的状态和运行情况。您可以通过线程池的监控机制来查看线程池中活动线程的数量、任务队列的大小等信息,从而更好地了解系统的运行情况。
- 避免线程创建的饥饿和耗尽: 在高并发的场景下,频繁地创建和销毁线程可能会导致系统的线程资源被耗尽或者产生线程创建的饥饿问题,而线程池可以通过预先创建一定数量的线程来避免这些问题。
总的来说,线程池可以提高系统的资源利用率、降低系统开销、提高系统的稳定性和响应速度,是多线程编程中的一种重要的并发控制机制。
线程池的使用
创建线程池对象:Java提供了Executors 类来帮助我们建立和使用线程池。Executors 类提供了一个静态工厂方法来创建线程池对象,常见的方法包括:
- newFixedThreadPool(int nThreads): 创建一个固定大小的线程池,该池中的线程数量固定为 nThreads。
- newCachedThreadPool(): 创建一个可以根据需要自动扩展的线程池,该池中的线程数量根据任务数量动态调整。
- newSingleThreadExecutor(): 创建一个单线程的线程池,该池中只有一个线程在工作,其他任务会进入任务队列等待执行。
提交任务: 使用线程池的 execute() 方法提交任务,任务可以是 Runnable 对象或者 Callable 对象。线程池会自动调度任务并在合适的时间执行它们。
关闭线程池: 当不再需要线程池时,需要调用线程池的 shutdown() 方法来关闭线程池。这会导致线程池停止接受新任务,并尝试将已提交但尚未执行的任务执行完成。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ThreadPoolExample {
public static void main(String[] args) {
// 创建一个固定大小的线程池,线程数量为3
ExecutorService executor = Executors.newFixedThreadPool(3);
// 提交10个任务给线程池执行
for (int i = 0; i < 10; i++) {
final int taskNumber = i;
executor.execute(() -> {
System.out.println("Task " + taskNumber + " executed by thread: " + Thread.currentThread().getName());
});
}
// 关闭线程池
executor.shutdown();
}
}
在这个示例中,我们使用 Executors.newFixedThreadPool(3) 方法创建了一个固定大小为3的线程池。然后,我们提交了10个任务给线程池执行,线程池会自动调度这些任务并在合适的时间执行它们。最后,我们调用了 executor.shutdown() 方法来关闭线程池。
线程池的结构和原理
结构
线程池由线程池管理器(ThreadPoolExecutor)、工作队列(BlockingQueue)和线程池中的工作线程(Worker Thread)组成。
- 线程池管理器(ThreadPoolExecutor):线程池管理器负责创建、管理和调度线程池中的工作线程。它可以根据需要自动创建或销毁线程,并且可以调整线程池的大小。同时ThreadPoolExecutor类提供了丰富的方法和参数,可以根据具体需求进行配置。
- 工作队列(BlockingQueue):工作队列用于存储等待执行的任务。当线程池中的线程都在忙于执行任务时,新的任务将被放入工作队列中等待执行。Java中常用的工作队列包括ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue等。
- 工作线程(Worker Thread):工作线程是线程池中实际执行任务的线程。当有任务需要执行时,线程池会从工作队列中取出任务分配给空闲的工作线程执行。工作线程执行完任务后,会继续等待新的任务。
原理
- 当一个任务提交给线程池时,线程池首先尝试将任务分配给空闲的工作线程执行。
- 如果所有的工作线程都在忙于执行任务,任务将被放入工作队列中等待执行。
- 如果工作队列已满,且线程池中的线程数未达到最大线程数限制,线程池会创建新的线程执行任务。
- 如果线程池中的线程数已达到最大线程数限制,并且工作队列也已满,此时线程池会根据预定义的拒绝策略来处理新提交的任务,默认情况下为抛出RejectedExecutionException异常。
BlockingQueue
BlockingQueue(阻塞队列)是一个支持两个附加操作的队列:阻塞的插入和阻塞的移除。BlockingQueue的实现是线程安全的,可以在多线程环境中安全地进行操作,而无需额外的同步手段。
public class ArrayBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
final ReentrantLock lock;
...
public boolean offer(E e) {
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lock();//加锁
try {
if (count == items.length)
return false;
else {
enqueue(e);
return true;
}
} finally {
lock.unlock();//释放锁
}
}
}
同时Java提供了多种BlockingQueue的实现,如ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue等,每种实现都具有不同的特性和适用场景。
Callable和FutureTask
使用Runnable接口相比, Callable功能更强大些。
运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可cancel()取消任务的执行,还可get()获取执行结果。
Callable的执行方式
FutureTask方式
Future 是 Java 中的一个接口,用于表示异步计算的结果。它提供了一种方便的方式来管理异步任务的执行状态和获取任务的执行结果。Future 接口通常与 Callable 配合使用,Callable 用于表示异步任务,而 Future 用于获取任务的执行结果。
FutureTask 是 Future 接口的一个具体实现类,同时也是 RunnableFuture 接口的一个实现类,它实现了 Runnable 接口,因此可以作为线程的任务来执行。而FutureTask 实现的run方法中正是对Callable进行调用,并且FutureTask持有一个Thread成员变量可以用来执行任务。
FutureTask 通常用于包装 Callable 或 Runnable 对象,使其可以异步执行,并且可以获取任务的执行结果。它提供了一些方法来查询任务的执行状态、取消任务的执行以及获取任务的执行结果等。
线程池方式
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class CallableExample implements Callable<String> {
@Override
public String call() throws Exception {
// 在这里定义你的任务逻辑
Thread.sleep(2000); // 模拟一个长时间的计算任务
return "任务执行完成";
}
public static void main(String[] args) {
// 创建 ExecutorService
ExecutorService executorService = Executors.newSingleThreadExecutor();
// 创建 Callable 对象
Callable<String> callable = new CallableExample();
// 提交 Callable 任务给 ExecutorService
Future<String> future = executorService.submit(callable);
// 获取任务的执行结果
try {
String result = future.get(); // 这个方法会阻塞直到任务执行完成并返回结果
System.out.println("任务执行结果:" + result);
} catch (Exception e) {
e.printStackTrace();
}
// 关闭 ExecutorService
executorService.shutdown();
}
}
在上面的示例中,我们创建了一个实现了 Callable 接口的 CallableExample 类,并重写了 call() 方法来定义任务逻辑。然后,我们创建了一个 ExecutorService 对象,将 Callable 对象提交给执行器,并使用 Future 对象来获取任务的执行结果。最后,记得关闭 ExecutorService。
同步
synchronized
synchronized 是 Java 中用来实现线程同步的关键字,它可以用来确保多个线程不会同时访问共享资源,从而避免数据竞争和不一致的状态。
synchronized使用
在 Java 中,有三种方式来使用 synchronized 机制:
1.同步实例方法:使用 synchronized 关键字修饰非静态方法,确保同一对象的不同实例在执行该方法时的互斥性。
public synchronized void method() {
// 线程安全的代码
}
2.同步静态方法:使用 synchronized 关键字修饰静态方法,确保在整个类的范围内,同一时间只有一个线程执行该静态方法。
public static synchronized void staticMethod() {
// 线程安全的代码
}
3.同步代码块:使用 synchronized 关键字对代码块进行同步,可以指定加锁的对象,只有获取了该对象的锁才能执行同步代码块。
public void someMethod() {
synchronized (lockObject) {
// 线程安全的代码
}
}
synchronized原理
synchronized同步功能是由虚拟机来提供的,因此它的性能会随着虚拟机的版本变更而可能出现变化。
在Java虚拟机的内部实现中,每个对象都可以有一个关联的监视器(monitor),也可以理解为是对象内部的锁。一旦对象锁升级到重量级锁后,JVM就会为对象分配一个监视器,并在其对象头中存放一个指向该监视器的指针。当一个线程进入synchronized代码块时,会尝试获取该对象的监视器,也就是获取对象锁。如果该对象当前没有被其他线程持有(也就是未被锁定),那么当前线程就会成功获取到该对象的锁,并且该对象的监视器计数器会增加1。这个计数器的作用是用来支持重入锁的,当同一个线程多次进入同步代码块时,计数器会累加,退出同步代码块时会递减。只有当计数器归零时,锁才会被释放。
在编译时,Java编译器会将synchronized关键字转换成对monitorenter和monitorexit指令的调用。当线程进入synchronized代码块且获取到锁时,会执行monitorenter指令来对监视器计数+1。而当线程退出synchronized代码块时,会执行monitorexit指令将监视器计数器减一。当计数器归零时,锁会被完全释放,其他线程可以再次竞争获取该对象的锁。
这种机制确保了对共享资源的访问是互斥的,同时也支持了重入锁的特性,使得同一个线程可以多次获取同一个对象的锁而不会发生死锁。
public class Example {
private static final Object lock = new Object();
public void synchronizedMethod() {
// monitorenter 指令,获取锁
synchronized (lock) {
// 同步代码块
}
// monitorexit 指令,释放锁
}
}
同步代码块的synchronized是可以指定锁对象的,如果不指定,那锁对象就是当前实例。而synchronized方法的锁对象就是当前实例。对于同步静态方法,锁对象是该类的 Class 对象。
Moniter
事实上,Moniter也叫作管程,它是一种机制,一种概念。在不同的语言或系统上有着不同的实现。它的出现是为了解决多线程对临界变量的同步互斥访问问题。即同一时间,只能有一个线程进入moniter定义的临界区,从而达到互斥的效果。同时,只有互斥是不够的,无法进入临界区的线程应当被阻塞,且能够在必要的时候被唤醒。因此Moniter需要提供了一个阻塞队列和等待队列来分别存放没有获取到锁而被阻塞的线程和因为其他条件而放弃锁的线程。
实际上在Moniter的定义中,允许有多个互斥量。而在Java的Moniter的实现中,只允许单个互斥量。
而在Java中,线程的阻塞、等待和唤醒是由Object类中的几个方法来实现的,比如wait()、notify()和notifyAll()。这些方法允许线程在对象的监视器上等待或者被唤醒。
注意,不同的jvm厂商会使用不同的方式来实现moniter,比如操作系统提供的互斥锁。
在Java虚拟机(HotSpot)中,对象监视器主要是基于C++中ObjectMonitor结构体中的EntrySet、WaitSet两个队列以及计数器count实现的。
- 当有多个线程同时想获取某个对象锁时,首先会进入EntryList队列
- 当某个线程获取到对象锁时,线程成为对象锁的拥有者,准备开始运行加锁代码块时,执行字节码指令monitorenter,此时count ++
- 当对象锁的拥有者线程再次获取锁时,由于synchronized锁是可重入的,此时进行count ++,而不是在EntryList队列中阻塞等待锁;
- 每个加锁代码块运行完成或因发生异常退出时,会执行monitorexit字节码指令,此时count --;,当count变为0是,对象锁的拥有者线程释放锁。
- 拥有锁的线程在运行过程中调用了wait()方法,那么线程会进入到WaitSet对象,等待被notify()或等待的时间已到,才有可能再次成为对象锁的拥有者。
偏向锁、轻量级锁和重量级锁
在早期,Java中的 synchronized 关键字使用的是重量级锁机制。重量级锁是一种在并发情况下保证数据一致性的强力手段,但是它的性能开销较大。当一个线程持有锁时,其他试图获得该锁的线程会被阻塞,导致线程上下文切换和内核态与用户态的切换,这些操作会耗费较多的系统资源和时间。
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源。因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
- 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
- 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
因此在JDK1.6后,引入偏向锁、轻量级锁和重量锁的概念来进行 synchronized 性能优化。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
下图是普通对象实例与数组对象实例的数据结构:
对象头
HotSpot虚拟机的对象头包括两部分信息:
-
markword:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“MarkWord”。它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容
32位虚拟机在不同状态下markword结构如下图所示: -
klass:对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.
在 Java 虚拟机中,对象头的一部分确实包含了指向该对象所属类的 klass 类型指针(klass pointer)。这个 klass 指针实际上指向了 Java 虚拟机内部的一个结构体,称为 klass 对象(有时也称为 klass metadata 或 klass metaobject)。
klass 对象是 Java 虚拟机内部用来表示类的元数据结构。它存储了关于类的一些重要信息,比如类的类型、继承关系、方法表、字段表、类加载器等等。每个类在 Java 虚拟机中都有对应的 klass 对象,这个对象是 Java 虚拟机内部进行类加载、方法调用、字段访问等操作的重要依据。
klass 对象和 Java 中的 Class 对象之间存在一定的关系,但它们并不是完全一致的。Java 中的 Class 对象是反映 Java 类的 Java 编程语言对象,可以通过 Java 反射 API 来获取类的信息,包括类的方法、字段、注解等。而 klass 对象是 Java 虚拟机内部的数据结构,用于支持虚拟机的运行,一般无法直接通过 Java 语言访问。
Java 虚拟机在内部使用 klass 对象来表示类的元数据,而在 Java 编程语言中,通过 Class 对象来访问类的元数据。尽管它们有一定的联系,但是它们的具体实现和用途是不同的。
数组长度(只有数组对象有)
偏向锁、轻量级锁和重量级锁
- 无偏向锁:当一个实例未被当做锁时,他就处于一种无偏向的状态。此时,它的对象头会被保存它的分代年龄和它的hashcode。
- 偏向锁:一旦目标实例被作为锁,它就会进入一种偏向锁的状态。它的对象头的hashcode部分会被替换为将他作为锁的线程的ID,默认目标线程在不存在争用条件下一直拥有锁。之所以存在偏向锁我认为是为了判断一个锁是否存在争用情况。一旦一个线程发现目标锁对象是偏向锁且线程ID不是自己,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
优点:在偏向锁状态下,除了第一次有加锁开销,之后的加锁和解锁不需要额外开销。除非锁被升级了。
缺点:如果发生了锁争用,需要先释放锁才能加锁。
适合场景:单线程 - 轻量级锁:当多个线程竞争同一个锁时,偏向锁会升级为轻量级锁。轻量级锁使用CAS操作来避免阻塞线程。当线程CAS失败时,会不断通过自旋持续进行锁的争用。当线程长期自旋都未获得锁,就意味着此时锁的争用情况激烈,需要进行锁的升级,防止CPU资源被自旋无谓消耗。自旋的次数可以通过-XX:PreBlockSpin参数设置。
优点:锁的竞争不会导致线程阻塞,在锁竞争情况不激烈的情况下,消除了线程挂起和唤醒的开销,提高了程序的响应速度。
缺点:自旋会消耗CPU资源,当争用线程过多时,大量线程的自旋会消耗过多的CPU资源。
适合场景:线程争用比较小的情况,同时追求响应速度,且单个线程程度执行时间短。 - 重量级锁:重量级锁是JVM中最重的锁。当线程获取锁失败时,会进入阻塞态等待锁被释放。当锁从轻量级锁升级到重量级锁后,JVM会为这个锁对象在堆中生成一个监视器,并将对象头用于保存分代信息和hashcode信息的位置拿来保存该监视器的指针。这个监视器中包含了一些信息,比如持有锁的线程、等待锁的线程队列等。其他线程尝试获取同一个对象的锁时,会进入到对象的等待队列中,直到持有锁的线程释放锁后才能继续竞争。因此,监视器中也存在wait()、notify() 和 notifyAll() 等方法用于唤醒等待的线程。前面提到synchronized会生成两个指令来对这个监视器进行操作,这个监视器就是充当互斥信号量的作用。
一旦线程获取锁失败,会将线程切换到内核态来对线程进行阻塞。
优点:未获取到锁的线程会进入阻塞态,不会消耗CPU
缺点:阻塞线程和唤醒线程需要消耗额外资源。
适合场景:线程执行时间比较长。
锁类
Java在线程的同步互斥方面除了提供了synchronized外,还提供了另外的锁类。Lock接口提供了比synchronized更多的锁操作,并且可能提供对多个互斥条件的支持。这弥补了synchronized在多条件上的缺陷。
Lock接口
锁是一种用于控制多个线程对共享资源的访问的工具。通常,锁提供对共享资源的独占访问:一次只有一个线程可以获取该锁,而对该共享资源的所有访问都要求首先获取该锁。但是,某些锁可能允许对共享资源进行并发访问,例如 ReadWriteLock的读锁。
与synchronized相比,后者会自动实现对锁的获取和释放,同时,对于多个锁,它们也会以相反的顺序进行释放。这使得开发者能够很轻松地使用这个关键字来实现同步互斥,并有助于避免许多涉及锁的常见编程错误,但在某些情况下,我们需要以更灵活的方式来使用锁。例如链式锁定(获取A锁,然后再获取B锁,再释放A获取C,释放B获取D,依次推类)。这是synchronized无法实现的。而Lock接口的实现允许在不同范围内获取和释放锁,并允许以任何顺序获取和释放多个锁,从而允许使用这些技术。(虽然Lock接口提供了更灵活的锁操作,但这意味着我们需要像C++的内存手动释放一样保证锁的释放。)
锁接口定义了多种锁操作:
- lock():加锁失败会阻塞
- lockInterruptibly():允许加锁过程被中断,在加锁加锁过程中被中断会抛出中断异常
- tryLock():加锁失败返回fasle,不会阻塞
- tryLock(long time, TimeUnit unit):会在时间范围内类似自旋锁一样不断尝试加锁。只是没有自旋锁那么频繁。
- unlock():释放锁
- newCondition():返回绑定到此Lock实例的新Condition实例。这个Condition实例可以认为是一个互斥条件。通过newCondition(),一个Lock实例可以具备多个互斥条件。在等待这个互斥条件之前我们需要先获取到锁。同时通过Condition.await()释放锁来等待条件,这与synchronized的wait()有些类似。
ReentrantLock类
ReentrantLock类是Lock接口对synchronized的复刻,其具备与synchronized相似的语义和行为,但提供了额外的扩展功能。属于API层面的互斥锁。
ReentrantLock由上次成功锁定但尚未解锁的线程所拥有。当锁不属于另一个线程时,调用lock()的线程将返回并成功获取锁。如果当前线程已经拥有锁,则该方法将立即返回。ReentrantLock提供了isHeldByCurrentThread和getHoldCount两个方法判断这些情况。
ReentrantLock的构造函数接受一个可选的公平性参数。当设置为true时,在争用下,锁定优先考虑等待时间更长的线程。tryLock()方法不尊重公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。
原理
ReentrantLock类是基于AbstractQueuedSynchronizer(AQS)类来实现同步互斥。
AQS提供了同步机制。由于我们需要复刻synchronized关键字的同步互斥机制(实际上是复刻Moniter),因此我们需要向Moniter一样,对获取锁失败的线程进行阻塞,且保持一个阻塞队列和一个等待队列来防止阻塞的线程和等待线程。
- 获取锁:对于获取锁的行为,AQS是通过被vloatile修饰的状态变量和原子CAS操作(UNSafe提供的)实现的。对于重入锁的实现,多次加锁会将状态变量+1来进行加锁计数。
- 线程阻塞:当一个线程获取锁失败,就需要进入阻塞态,并放入阻塞队列中等待被唤醒。而阻塞的能力是通过Unsafe的park()方法来实现的,同时进入这个线程也会进入AQS的阻塞队列(公平锁是一个volatile链式队列)中。
- 锁释放:与获取锁一致。放状态为0时,AQS会唤醒阻塞队列中的一个线程来获取锁。
- Condiation:与synchronized关键字相比,ReentrantLock类允许有多个条件变量。因此,在Condiation对象同样保持了一个等待线程,用于存放等待自己的线程。一旦条件获取失败,锁同样进入等待队列并阻塞。
Volatile
volatile是Java中的关键字,是Java用来修饰域的同步机制。与synchronzied相比,它更加轻量级。但是却无法完全保证线程安全。
并发编程的三大特性:
- 原子性:就是一个操作或多个操作中,要么全部执行,要么全部不执行。
- 可见性:可见性是指多线程共享一个变量,其中一个线程改变了变量值,其他线程能够立即看到修改的值。
- 有序性:程序执行顺序按照代码先后顺序执行。
只要有一条原则没有被保证,就可能会导致程序运行不正确。volatile关键字提供了两个作用:内存可见性和禁止进行指令重排序。前者被用来保证可见性,即保证共享变量的内存可见性以解决缓存一致性问题。后者带来了程序的有序性,避免指令被编译器进行重排序。
指令重排序的作用是为提高硬件的使用率,尽量保证指令流水线不发生中断。因此编译器会调整没有依赖关系的指令执行的顺序。但在多线程情况下,由于不同线程之间的数据依赖性不被考虑,因此对于一些变量来说,指令重排序会使得执行的结果不稳定。
内存可见性
在JMM层面,volatile修饰的共享变量在执行写操作后,会立即刷回到主存,以供其它线程读取到最新的记录。
而在CPU层面,被volatile修饰的变量在写操作生成汇编指令时,会多出lock前缀指令。借助lock前缀指令,进行缓存一致性的缓存锁定方案,通过总线嗅探和MESI协议来保证多核缓存的一致性问题,保证多个线程读取到最新内容。lock前缀指令除了具有缓存锁定这样的原子操作,它还具有类似内存屏障的功能,能够保证指令重排的问题。
事实上,lock前缀指令是jvm在x86架构上的实现,对于不同的平台,jvm会使用不同的方式实现内存可见性。但总的来说应该都是通过cpu层面的缓存一致性协议来实现的。
MESI是一种缓存一致性协议,该协议保证CPU对变量的修改会直接被刷新到主存,并无效化其他cpu缓存中的这个变量的副本。每个CPU核心通过嗅探在总线上传播的数据来检查自己的缓存是不是被修改,· 当 CPU 发现自己缓存行对应的内存地址被修改,会将当前 CPU 的缓存行设置成无效状态,重新从内存中把数据读到 CPU 缓存。从而保证每个线程的数据是最新的。
有序性
Volatile关键字在JMM层面提供了内存屏障的功能,能够Volatile变量在读写操作前后都会进行屏障的插入来保证执行的顺序不被编译器等优化器锁重排序。这种内存屏障的机制是基于Happens-Before规则实现的。
Happens-Before规则是一组规则,用于确保多线程程序中操作的顺序性和一致性。Happens-Before规则定义了一些条件,如果这些条件被满足,则一个操作“happens-before”另一个操作,从而保证了操作的顺序性。
以下是Java中Happens-Before规则的一些重要情况:
- 程序顺序规则:在单线程中,程序顺序规则确保了程序中的每个操作按照代码中的顺序执行。
- 监视器锁规则:对一个锁的解锁操作happens-before于对这个锁的加锁操作。这意味着在一个线程中,释放锁的操作在获得锁的操作之前发生。
- volatile变量规则:对volatile变量的写操作happens-before于后续对同一变量的读操作。这确保了volatile变量的修改对所有线程是可见的。
- 传递性:如果事件A happens-before事件B,且事件B happens-before事件C,那么事件A happens-before事件C。这个规则允许通过传递性来确定事件之间的happens-before关系。
为什么volatile无法保证线程安全
单纯使用 volatile 关键字是不能保证线程安全的。前面提到,并发编程需要保证可见性、有序性和原子性三大原则。而volatile只能保证前两者。对于原子性是无法保证的,它只提供了一种弱的同步机制,用来确保将变量的更新操作通知到其他线程。对于像i++这样的操作,由于在编译后,会被转化为load、+1和write三个操作,因此是存在线程不安全的可能性的。
举个例子,当我们对volatile变量i执行完load操作并完成+1操作后,需要将其写回主存。但此时线程切换,且另一个线程也完成了对i的写操作并写回主存。由于所有线程对i操作时都需要重写从主存读值,因此可以使用最新的i值。但对最前面的线程来说,他已经完成读值操作,已经进入写回指令。那么此时它就无法对最新的i值进行操作了。换到CPU的缓存一致性协议层面,就是线程的缓存虽然因为其他CPU核心的写操作而被无效化,但是该CPU已经进入写操作。因此不受影响。
通常如果希望volatile具备线程安全的能力,会结合同步互斥机制或原子性的CAS操作来实现。
Unsafe
Java中的Unsafe是一个位于sun.misc包下的类,提供了一系列底层操作,允许Java程序直接操作内存和执行一些特权操作,通常是与Java语言规范相悖的操作。
由于Unsafe提供了许多直接操作内存的方法,因此它具有很高的风险,容易导致程序出现未定义的行为,包括内存泄漏、数据损坏以及安全漏洞等问题。因此,尽管Unsafe提供了一些非常有用的功能,但它并不是Java开发中推荐使用的方式。
Unsafe类中的方法通常被划分为几个主要类别:
- 内存操作:allocateMemory()、putXXX()、getXXX()等方法用于分配内存、读写原始数据类型。
- 对象操作:allocateInstance()、objectFieldOffset()、compareAndSwapXXX()等方法用于实例化对象、获取对象字段的偏移量以及CAS操作(这部分的cas操作是原子性的)。
3.** 数组操作**:arrayBaseOffset()、arrayIndexScale()、getObject()、putObject()等方法用于对数组进行基于偏移量的操作。 - 同步器和锁:park()、unpark()等方法用于线程的阻塞和唤醒。
- 其他操作:还有一些其他的方法,如throwException()用于抛出异常、addressSize()用于获取地址的大小等。
需要注意的是,Unsafe类在Java 9中被标记为了@Deprecated,并且在Java 17中已经被移除。因此,它并不是一个推荐的或者长期支持的API。在新的Java版本中,应该尽可能地避免使用Unsafe,而是使用更加安全和标准的API来完成相同的任务。
JUC
JUC是Java并发编程的一部分,它指的是Java Util Concurrent,即Java工具包下的并发编程工具。Java并发编程是指在多线程环境下编写程序以利用多核处理器和提高程序性能的技术。在Java中,JUC提供了一系列的工具和类,帮助开发者编写高效且线程安全的并发代码。
JUC包含了许多实用的类和接口,主要分为以下几个部分:
- 并发集合类:例如ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet等,这些类提供了线程安全的集合操作,适用于并发环境下的高效读写操作。
- 并发队列:例如ConcurrentLinkedQueue、LinkedBlockingQueue、ArrayBlockingQueue等,这些队列实现了多线程环境下的高效操作,并提供了多种阻塞和非阻塞的操作方式。
- 原子变量:例如AtomicInteger、AtomicLong、AtomicReference等,这些类提供了基本数据类型的原子操作,保证了对变量的操作是线程安全的。
- 锁框架:例如ReentrantLock、ReadWriteLock、StampedLock等,这些类提供了更灵活的锁机制,支持了更复杂的线程同步和互斥操作。
- 并发工具类:例如CountDownLatch、CyclicBarrier、Semaphore等,这些工具类提供了更高级的线程同步和控制机制,用于解决一些复杂的并发问题。
通过使用JUC提供的类和接口,开发者可以更方便地编写高效且线程安全的并发程序,提高程序的并发性能和可维护性。JUC在Java 5中首次引入,随后在后续的版本中持续增强和优化。