并发编程.md 13 KB

为什么wait和notify必须放在synchronized中?

在Java中,wait()和notify()是Object类的两个方法,它们用于实现线程间的协作。wait()使一个线程进入等待状态,直到另一个线程发出通知唤醒它。而notify()则用于唤醒正在等待的线程。

wait()和notify()必须放在synchronized块中是因为这些方法依赖于对象的监视器锁(也称为互斥锁)。只有获得了对象的监视器锁(该锁即为调用wait方法的对象)的线程才能调用wait()和notify()方法。如果这些方法不在同步块中使用,就无法保证线程安全性。

当一个线程调用wait()方法时,它会释放持有的锁并进入等待状态,等待其他线程调用notify()方法来唤醒它。如果wait()方法不在同步块中使用,那么该线程在释放锁之后可能已经被其他线程修改了。这样,即使其他线程调用了notify()方法唤醒了它,该线程也无法正确地处理唤醒事件。

同样,notify()方法也必须在同步块中使用。如果一个线程在未获得锁的情况下调用notify()方法,那么它将无法通知任何等待的线程。因为它没有获取锁,所以它不能访问共享数据或执行必要的同步操作来确保正确的通知。

因此,使用wait()和notify()方法时,必须在同步块中使用它们,以确保线程之间的安全性并避免出现竞态条件。

volatile 关键字

在Java中,volatile关键字是用于保证多线程环境下变量的可见性和有序性。主要分为两大功能。

  1. 保证变量可见性:被volatile关键字声明代表变量是共享且不稳定的,每次使用它都到主存中进行读取,并且强制刷新到内存。
  2. 进制指令重排序:JVM 具有指令重排的特性,可以保证程序执行效率更高,但是volatile关键字会进制指令重排保证其有序性。

volatile 关键字底层原理

volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。

JMM层面的“内存屏障”:

  • LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作执行前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:

LoadLoad volatile 读操作 LoadStore

StoreStore volatile 写操作 StoreLoad

禁止指令重排,汇编层面

lock 前缀:lock不是内存屏障,而是一种锁。执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU

synchronized 关键字

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在JDK1.6之前synchronized关键字基于操作系统mutux互斥变量实现,该操作较为重量级,但是在JDK1.6之后进行的JVM层面的锁优化。效率大大提升,优化后的锁主要分为一下四类

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态

锁可以升级,但不能降级。即:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁是单向的。首先程序判断该对象是否有锁,如果没有锁则将无锁状态转换为偏向锁状态,这里通过CAS操作进行加锁,如果加锁成功之后,就不会再有解锁等操作了。假如有两个线程来竞争该锁话,那么偏向锁就失效了,进而升级成轻量级锁了。

轻量级之所以是轻量级锁,是因为它仅仅使用 CAS 进行操作来获取锁。如果获取成功那么会直接获取锁,如果失败,当前线程便尝试使用自旋来获取锁。当竞争线程的自旋次数达到界限值(threshold),轻量级锁将会膨胀为重量级锁。

重量级锁(heavy weight lock),是使用操作系统互斥量(mutex)来实现的传统锁。 当所有对锁的优化都失效时,将退回到重量级锁。它与轻量级锁不同竞争的线程不再通过自旋来竞争线程, 而是直接进入堵塞状态,此时不消耗CPU,然后等拥有锁的线程释放锁后,唤醒堵塞的线程, 然后线程再次竞争锁。但是注意,当锁膨胀(inflate)为重量锁时,就不能再退回到轻量级锁。

synchronized 和 volatile 有什么区别?

  • volatile 关键字是线程同步的轻量级实现,所以性能肯定比synchronized关键字要好 。

  • volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。

  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ReentrantLock锁

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,他的底层是基于CAS+AQS+LockSupport实现的

  • CAS

CAS是Compare and Swap的缩写,即比较并交换。它是一种无锁算法,在多线程编程中用于实现同步操作。简单来说,CAS操作包括三个操作数:内存位置V、预期值A和新值B。当且仅当内存位置V的值等于预期值A时,才将该位置的值更新为新值B。

  • AQS

AQS抽象队列同步器。它是Java并发包中锁和同步工具的核心实现上面的所说的LOCK锁就是基于AQS实现的

AQS提供了一种通用的框架,用于实现线程间的协作和同步操作。它的核心思想是使用一个先进先出的等待队列来管理线程状态,同时支持独占模式和共享模式两种同步方式。

image-20230506170616683

  • LockSupport

基于C语言底层实现,它可以阻塞线程以等待许可证,或者取消线程的阻塞状态,而不需要使用传统的synchronized关键字或Object.wait()/notify()方法。

synchronized 和 ReentrantLock 有什么区别?

  • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
  • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。
  • synchronized是以代码块形式实现的,因此只能对整个代码块进行同步操作,无法在代码块内部实现一些细粒度的控制。而ReentrantLock可以通过Condition对象实现线程间的协作和控制。

LockSupport和synchronized的区别

  • LockSupport不需要获取锁对象,因此避免了可能出现的死锁问题。

  • LockSupport可以响应中断,而Object.wait()/notify()方法无法响应中断。

  • LockSupport可以先执行unpark()方法,然后再执行park()方法,而Object.notify()方法必须在对应的wait()方法之前执行。

线程池

线程池就是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。线程池的优点和好处主要有以下三点

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

常见的线程池:

  • FixedThreadPool:该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。

  • SingleThreadExecutor 该方法返回一个只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。

  • CachedThreadPool 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

  • ScheduledThreadPool:该返回一个用来在给定的延迟后运行任务或者定期执行任务的线程池。

为什么不推荐使用内置线程池?

  • FixedThreadPoolSingleThreadExecutor:使用的是无界的 LinkedBlockingQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

  • CachedThreadPool:使用的是同步队列 SynchronousQueue, 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

  • ScheduledThreadPoolSingleThreadScheduledExecutor : 使用的无界的延迟阻塞队列DelayedWorkQueue,任务队列最大长度为 Integer.MAX_VALUE,可能堆积大量的请求,从而导致 OOM。

总之一句话,他们的队列长度都是MAX_VALUE,可能导致OOM

线程池核心参数

  • corePoolSize : 任务队列未达到队列容量时,最大可以同时运行的线程数量。

  • maximumPoolSize : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。

  • workQueue: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

  1. 当线程数小于核心线程数时,创建线程。

  2. 当线程数大于等于核心线程数,且任务队列未满时,将任务放入任务队列

  3. 当线程数大于等于核心线程数,且任务队列已满

    • 若线程数小于最大线程数,创建线程。

    • 若线程数等于最大线程数,抛出异常,拒绝任务,执行拒绝策略

线程池的饱和策略有哪些?

  • ThreadPoolExecutor.AbortPolicy 抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy 调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy 不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy 此策略将丢弃最早的未处理的任务请求。

我们这里使用自定义的饱和策略,即为新建一个线程去处理饱和任务

如何设定线程池的大小?

  • CPU 密集型任务(N+1)

  • I/O 密集型任务(2N)

CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。

ThreadLocal

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的,想要每一个线程都有自己私有的本地变量就就需要使用ThreadLocal类,ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。

ThreadLocal底层为每一个线程维护了一个ThreadLocalMap,他的set方法如下

public void set(T value) {
    //获取当前请求的线程
    Thread t = Thread.currentThread();
    //取出 Thread 类内部的 threadLocals 变量(哈希表结构)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 将需要存储的值放入到这个哈希表中
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

ThreadLocal 内存泄露问题是怎么导致的?

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以,如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,key 会被清理掉,而 value 不会被清理掉。

这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry。假如我们不做任何措施的话,value 永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,使用完 ThreadLocal方法后最好手动调用remove()方法