多线程引入可能会导致的问题

  • 从基础角度来说,死锁、线程安全、内存泄漏(线程分配的内存没有被正确释放)。
  • 从其他角度来说,多线程访问文件,Linux 的文件描述符打开有上限,类似的还有网络连接的建立。
  • 线程也是资源,创建线程数目过多的时候,内存 OOM。这也是线程池中,为什么不支持无限长度的等待队列。

死锁问题

线程死锁在 Java 中的体现

死锁的发生有四个条件:(1)互斥资源(2)占有并等待(3)不可抢占(4)循环等待
那么对应于 Java 中就属于,

  • synchronized 关键字锁住的对象就是互斥资源。
  • 线程 A 获得资源 a,线程 B 获得资源 b,然后分别期望获得资源 b 和资源 a,此时处于占用并等待与循环等待中。
  • 由于 synchronized 锁住资源,占有的资源不可被抢占。

如何检测 Java 线程死锁

  • 使用 jmapjstack 等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,jstack 的输出中通常会有 Found one Java-level deadlock: 的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用 topdffree 等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。
  • 采用 VisualVM、JConsole 等工具进行排查。

JConsole

首先,我们要找到 JDK 的 bin 目录,找到 jconsole 并双击打开。

对于 MAC 用户来说,可以通过 /usr/libexec/java_home -V 查看 JDK 安装目录,找到后通过 open . + 文件夹地址 打开即可。例如,我本地的某个 JDK 的路径是:

open . /Users/guide/Library/Java/JavaVirtualMachines/corretto-1.8.0_252/Contents/Home

打开 jconsole 后,连接对应的程序,然后进入线程界面选择检测死锁即可!

|400

800|500

或者一个个点击查看线程信息也可以:

800

jstack

jstack 是 JDK 自带的一个命令行工具,可以用来打印 Java 虚拟机(JVM)中所有线程的堆栈跟踪信息。通过分析这些堆栈跟踪信息,可以发现潜在的死锁情况。

jstack <pid>
jstack 12345 > jstack.log

将堆栈跟踪信息保存到 jstack.log 文件中,然后分析该文件。

如何避免死锁

  1. 破坏请求与保持条件:一次性申请所有的资源。
  2. 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  3. 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

线程安全问题

一方面是基础数据结构的线程安全性,例如 String、Vector 等数据结构。另一方面是多线程下同一个数据线程安全性 —— ThreadLocal 来解决。

ThreadLocal 简介

每一个线程都有自己的专属本地变量。如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是 ThreadLocal 变量名的由来。他们可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。

private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));

ThreadLocal 原理

每一个 Thread 下面有一个 ThreadLocal.ThreadLocalMap 对象。ThreadLocalMap 下有个 Entry 数组,每个 Entry 代表一个对象。Entry 构造函数包含两个数据,一个是当前的 ThreadLocal 对象,一个是 Object 对象。存对象时,是向当前线程的 ThreadLocalMap 中存储数据;读对象时,也是根据当前 ThreadLocal 对象来获取对应的结果。从而线程隔离。

ThreadLocal 类

ThreadLocal 内存泄露问题

需要注意的是,Extry 对象继承了弱引用,存在内存泄漏的问题。即,ThreadLocal 被垃圾回收后,ThreadLocalMap 属于 Thread 的成员变量,与 Thread 的生命周期相同,因此出现 key 不存在,value 仍存在的情况。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()get()remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal 方法后最好手动调用 remove() 方法。

class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ....
}
class ThreadLocal<T> {
    static class ThreadLocalMap {
        private Entry[] table;
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        private Entry getEntry(ThreadLocal<?> key) {...   }
        private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { ...    }
        private void set(ThreadLocal<?> key, Object value) {  ....   }
        private void remove(ThreadLocal<?> key) {  ...  }
    }
}

数据可见性问题

如果对于一个数据的最新修改可以被其他线程看到,那么需要使用 volatile 关键字。

Java 内存模型(JMM)

JMM(Java 内存模型)主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性。

CPU 缓存模型

CPU 有 cache,用来进行缓存数据。但是如果在某个 CPU cache 上修改了数据,其他 CPU 在访问该数据的时候,使用的还是旧的数据值,那么就存在数据不一致的情况了。所以,针对这个情况,就有了缓存一致性协议,如 MESI。即,当 CPU cache 上的某个数据修改后,其他 CPU cache 上同一个数据会失效,从而去获得最新的数据值。

|400

指令重排

为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。

什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。

  • 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
  • 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

什么是 JMM?为什么需要 JMM?

在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
所以, volatile 关键字除了防止 JVM 的指令重排,还有一个重要的作用就是保证变量的可见性。

volatile 关键字

volatile 的使用及其原理

volatile 的两层语义

  1. 避免了指令重排优化,实现了有序性。
  2. volatile 保证变量对所有线程的可见性:当 volatile 变量被修改,新值对所有线程会立即更新。或者理解为多线程环境下使用 volatile 修饰的变量的值一定是最新的。

volatile 的原理:

  1. 获取 JIT(即时 Java 编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现 volatile 多加了 lock addl 指令,这个操作相当于一个内存屏障,使得 lock 指令后的指令不能重排序到内存屏障前的位置。这也是为什么 JDK1.5 以后可以使用双锁检测实现单例模式。
  2. lock 前缀的另一层意义是使得本线程工作内存中的 volatile 变量值立即写入到主内存中,并且使得其他线程共享的该 volatile 变量无效化,这样其他线程必须重新从主内存中读取变量值。

具体原理见这篇文章:https://www.javazhiyin.com/61019.html

如何禁止指令重排序?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和 volatile 禁止重排序一样的效果,只是会麻烦一些。

下面我以一个常见的面试题为例讲解一下 volatile 关键字禁止指令重排序的效果。

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

public class Singleton {
 
    private volatile static Singleton uniqueInstance;
 
    private Singleton() {
    }
 
    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 132。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance () 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

说说 synchronized 关键字和 volatile 关键字的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性
  • volatile 关键字只能用于变量synchronized 关键字可以修饰方法以及代码块
  • 多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞。

临界资源访问问题

Java 中乐观锁的实现

CAS 机制解释参考:乐观锁 & 悲观锁

Unsafe 类的 cas 方法

sun.misc 包下的 Unsafe 类提供了 compareAndSwapObjectcompareAndSwapIntcompareAndSwapLong 方法来实现的对 Objectintlong 类型的 CAS 操作。

public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);
 
public final native boolean compareAndSwapInt(Object o, long offset, int expected,int update);
 
public final native boolean compareAndSwapLong(Object o, long offset, long expected, long update);

关于 Unsafe 类的详细介绍可以看这篇文章:Java 魔法类 Unsafe 详解 - JavaGuide - 2022 。

atomic 原子包

java.util.concurrent.atomic 包提供了一些用于原子操作的类。这些类利用底层的原子指令,确保在多线程环境下的操作是线程安全的。

具体参考 Java 并发 - JUCAtomic 包 章节。

synchronized 关键字

synchronized 关键字的了解

  1. 可见性
  2. 操作原子性
  3. 禁止指令重排

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

另外,在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。

synchronized 的使用

synchronized 关键字最主要的三种使用方式:

  • synchronized 关键字加到 static 静态方法上是给 Class 类上锁。
  • synchronized 关键字加到实例方法上是给对象实例上锁。
  • synchronized(this|object) 表示进入同步代码块前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得当前 class 的锁。

💡:尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

synchronized 关键字的使用

  • Collections.synchronizedList()
  • 单例模式

双重校验锁实现对象单例(线程安全)

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() { }
    public  static Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

volatile: (1)为 uniqueInstance 分配内存空间 (2) 初始化 uniqueInstance (3)将 uniqueInstance 指向分配的内存地址

synchronized 修饰构造方法

构造方法不能使用 synchronized 关键字修饰。构造方法本身就属于线程安全的,不存在同步的构造方法一说。

synchronized 的底层原理

synchronized 同步语句块的情况
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("synchronized 代码块");
        }
    }
}
 

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行 javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出:

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor 实现的。每个对象中都内置了一个 ObjectMonitor 对象。

另外,wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。

在执行 monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
    public synchronized void method() {
        System.out.println("synchronized 方法");
    }
}
 

synchronized关键字原理

synchronized 修饰的方法是使用 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

synchronized 总结

synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

相关推荐:Java锁与线程的那些事 - 有赞技术团队open in new window 。

🧗🏻进阶一下:学有余力的小伙伴可以抽时间详细研究一下对象监视器 monitor

不过两者的本质都是对对象监视器 monitor 的获取。

synchronized 锁升级

在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多(JDK18 中,偏向锁已经被彻底废弃,前面已经提到过了)。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

synchronized 锁升级是一个比较复杂的过程,面试也很少问到,如果你想要详细了解的话,可以看看这篇文章:浅析 synchronized 锁升级的原理与实现

synchronized 的优化

  1. 锁逐步升级。从无锁-偏向锁-轻量锁-重量锁。
  2. 锁消除。去除不可能存在竞争的锁。例如申请函数内局部变量的锁。
  3. 锁粗化。扩大锁的范围避免重复加锁和释放锁。
  4. 自旋锁与自适应自旋锁。轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。
    • 自旋锁:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出 CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。
    • 但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放 CPU,会带来许多的性能开销。
    • 自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

为什么要引入偏向锁和轻量级锁?为什么重量级锁开销大?

重量级锁底层依赖于系统的同步函数来实现,在 linux 中使用 pthread_mutex_t(互斥锁)来实现。这些底层的同步函数操作会涉及到:操作系统用户态和内核态的切换、进程的上下文切换,而这些操作都是比较耗时的,因此重量级锁操作的开销比较大。

而在很多情况下,可能获取锁时只有一个线程,或者是多个线程交替获取锁,在这种情况下,使用重量级锁就不划算了,因此引入了偏向锁和轻量级锁来降低没有并发竞争时的锁开销。

关于这几种优化的详细信息可以查看下面这篇文章:Java6 及以上版本对 synchronized 的优化

synchronized 锁能降级吗?

可以的。具体的触发时机:在全局安全点(safepoint)中,执行清理任务的时候会触发尝试降级锁。

全局安全点可以处于的在位置:GC 等位置。

当锁降级时,主要进行了以下操作:

1)恢复锁对象的对象头(英文名:markword);
2)重置 ObjectMonitor,然后将该 ObjectMonitor 放入全局空闲列表,等待后续使用。

synchronized 非公平体现在哪些地方?

synchronized 的非公平其实在源码中应该有不少地方,因为设计者就没按公平锁来设计,核心有以下几个点:

  • 当持有锁的线程释放锁时,该线程会执行以下两个重要操作:
    1. 先将锁的持有者 owner 属性赋值为 null
    2. 唤醒等待链表中的一个线程(假定继承者)。
    3. 在 1 和 2 之间,如果有其他线程刚好在尝试获取锁(例如自旋),则可以马上获取到锁。
  • 当线程尝试获取锁失败,进入阻塞时,放入链表的顺序,和最终被唤醒的顺序是不一致的,也就是说你先进入链表,不代表你就会先被唤醒。

说说 synchronized 关键字和 volatile 关键字的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。
  • volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性
  • volatile 关键字只能用于变量synchronized 关键字可以修饰方法以及代码块
  • 多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞。

ReentrantLock

ReentrantLock 是基于 AQS 实现的可重入锁。具体参考 Java 并发 - JUCReentrantLock 章节。