前言本文承接【Java 并发之 synchronized 关键字深度解析（一）】，着重介绍 synchronized 几种锁的特性及其底层实现原理。一、对象头结构及锁状态标识synchronized 关键字是如何实现给对象加锁的？首先我们需要了解 Java 中对象的组成。Java 对象在内存中主要由三部分组成：对象头（Object Header）、实例数据（Instance Data）和对齐填充（Padding）。对齐填充：JVM 规定对象的起始内存地址必须是 8 字节的整数倍，如果不够则用占位符填充，此部分即为对齐填充。实例数据：对象存储的真正有效信息，即对象的成员变量信息（包括继承自父类的）。对象头：由两部分组成。第一部分是对象的运行时数据（Mark Word），包括哈希码、锁偏向标识、锁类型、GC 分代年龄、偏向线程 ID 等；第二部分是对象的类型指针（Klass Word），用于在堆中定位对象的实例数据和方法区中的类型数据。Java 对象的公共特性都存放在对象头中。对象头存储内容如下所示（以 64 位操作系统为例）：|--------------------------------------------------------------------------------------------------------------| | Object Header (128 bits.html) | |--------------------------------------------------------------------------------------------------------------| | Mark Word (64 bits.html) | Klass Word (64 bits.html) | |--------------------------------------------------------------------------------------------------------------| | unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 无锁 |----------------------------------------------------------------------|--------|------------------------------| | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | 偏向锁 |----------------------------------------------------------------------|--------|------------------------------| | ptr_to_lock_record:62 | lock:2 | OOP to metadata object | 轻量锁 |----------------------------------------------------------------------|--------|------------------------------| | ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | 重量锁 |----------------------------------------------------------------------|--------|------------------------------| | | lock:2 | OOP to metadata object | GC |--------------------------------------------------------------------------------------------------------------|其中 lock:2 表示有 2 bit 控制锁类型，biased_lock:1 表示 1 bit 控制偏向锁状态，对应关系如下所示：01：无锁（前面偏向锁状态为 0 时表示未锁定）01：可偏向（前面偏向锁状态为 1 时表示可偏向）00：轻量级锁10：重量级锁11：GC 标记看到前两种状态时读者可能会有些疑惑。JVM 的设计者想用 01 状态来表示两种情况（无锁和可偏向），但显然一个字符无法标识两种状态。因此，他们将前面一位暂时用不到的 bit 纳入进来，用前一位的值是 0 还是 1 来区分是无锁还是可偏向。二、锁的信息打印下面我们通过代码验证这几种锁的存在。JVM 默认开启偏向锁，默认的偏向锁启动时间为 4-5 秒后。因此，先让主线程睡眠 5 秒再加锁能保证对象处于偏向锁的状态。此外，也可以在 VM Options 中添加参数 -XX:BiasedLockingStartupDelay=0 来让 JVM 取消延迟启动偏向锁（本文的示例均未设置此参数），其效果跟不改变 VM Options 只在 main 方法中让主线程先睡眠 5 秒是一样的。此外，要打印对象存储空间需要引入 OpenJDK 的 jol-core 包依赖：<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>RELEASE</version> </dependency>User 对象代码：public class LockClient { static class User { public String name; public byte age; } }万事俱备，下面开始测试。1. 无锁状态先不睡眠五秒，此时偏向锁未开启，所以对象都是无锁状态（未加 synchronized 的情况下）。打印无锁状态的对象（锁标识 001）：@Test public void noLock() { User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }输出结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total下面我们来解读一下这个打印结果。通过 TYPE DESCRIPTION 可以知道，前三行打印的是对象头（object header），那么后面四行就是对象的实例数据和对其填充了。先看第一行，VALUE 中，标红的 001 表示当前对象是无锁状态，前面的 0 对应我们上面讲的可偏向锁状态为非偏向锁（如果是 1 表示偏向锁）。第三行存放的是对象指针。第四行和第六行存放的是对象的两个成员变量，第五行空间用于填充 age 变量；第七行就是我们所说的对齐填充，使对象内存空间凑齐 8 字节的整数倍。2. 偏向锁状态加上睡眠 5 秒：@Test public void biasedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }看看打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 00 00 00 (00000101 00000000 00000000 00000000.html) (5.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到，锁状态为 101 可偏向锁状态了，只是由于未用 synchronized 加锁，所以线程 ID 是空的。其余数据跟上述无锁状态一样。偏向锁带线程 ID 情况，代码如下：@Test public void synchronizedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } System.out.println(Thread.currentThread(.html).getId()); // System.out.println(Integer.toBinaryString(System.identityHashCode(Thread.currentThread(.html)))); User user = new User(); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }输出结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 d8 00 fc (00000101 11011000 00000000 11111100.html) (-67053563.html) 4 4 (object header.html) 0e 7f 00 00 (00001110 01111111 00000000 00000000.html) (32526.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可见第一行中后面不再是 0 了，有了线程 ID 的值。3. 轻量级锁状态再看看轻量锁，不睡眠 5 秒，直接用 synchronized 给对象加锁，此时触发的就是轻量锁。代码如下：@Test public void lightWeightLock() { System.out.println(Integer.toBinaryString(System.identityHashCode(Thread.currentThread(.html)))); User user = new User(); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) e0 59 8b 34 (11100000 01011001 10001011 00110100.html) (881547744.html) 4 4 (object header.html) 77 7f 00 00 (01110111 01111111 00000000 00000000.html) (32631.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到锁的标识位为 000，轻量级锁。4. 重量级锁状态最后看一下重量级锁，只有在锁竞争的时候才会变为重量级锁，代码如下：@Test public void heavyWeightLock() { User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t1 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(5000.html);// 睡眠，创造竞争条件 } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t1.start(); Thread t2 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t2.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }输出结果为：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 6a 62 00 50 (01101010 01100010 00000000 01010000.html) (1342202474.html) 4 4 (object header.html) 74 7f 00 00 (01110100 01111111 00000000 00000000.html) (32628.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到锁状态为 010，重量级锁。5. 调用 hashCode 会取消偏向此外，如果通过 Object 对象的本地 hashCode 方法来获取对象的 hashCode 值，会使对象取消偏向锁状态。@Test public void cancelBiasedLocking() { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); System.out.println(user.hashCode(.html)); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }打印结果：com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 00 00 00 (00000101 00000000 00000000 00000000.html) (5.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total 1644443712 com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 40 38 04 (00000001 01000000 00111000 00000100.html) (70795265.html) 4 4 (object header.html) 62 00 00 00 (01100010 00000000 00000000 00000000.html) (98.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) e0 29 99 e8 (11100000 00101001 10011001 11101000.html) (-392615456.html) 4 4 (object header.html) a8 7f 00 00 (10101000 01111111 00000000 00000000.html) (32680.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total可以看到，计算完对象的 hashCode 之后，该对象立即从偏向锁状态变为了无锁状态。即使后续给对象加锁，该对象也只会进入轻量级或者重量级锁状态，不会再进入偏向状态了。因为该对象一旦进行 Object 的 hashCode 计算，那么对象头中会保存这个 hashCode，此时再也无法存放偏向线程的 ID 了（因为对象头的长度无法同时存放 hashCode 和偏向线程 ID），所以此后该对象无法再进入偏向锁状态。三、锁膨胀过程到这里，我们一起看完了 synchronized 给对象加的各种锁状态以及触发场景，下面我们梳理一下它们之间的关系。JVM 启动后会默认开启偏向锁（默认 4-5 秒后开启），开启后，所有新建对象的对象头中都标识为 101 可偏向状态，且偏向线程 ID 为 0，表示处于初始化的偏向锁状态。此后一旦有线程对该对象使用了 synchronized 加锁，那么就会进入偏向锁状态，偏向线程 ID 记录当前线程 ID；如果走完同步块之后，有另一个线程对该对象加锁，那么膨胀为轻量级锁；如果未走完同步块就有另一个线程试图给该对象加锁，那么会直接膨胀为重量级锁（中间会有一个自旋锁的过程，此处略去）。1. 开启偏向锁开启偏向的锁膨胀草图下面演示一下对象从偏向锁膨胀为轻量级锁的过程：@Test public void biasedLockToLightWeightLock() throws InterruptedException { // 先睡眠 5 秒，保证开启偏向锁 try { Thread.sleep(5000.html); } catch (InterruptedException e.html) { // -XX:-UseBiasedLocking e.printStackTrace(); // -XX:BiasedLockingStartupDelay=0 } User user = new User(); Thread t1 = new Thread((.html) -> { synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } }); t1.start(); t1.join(); // 确保 t1 执行完了再执行当前主线程 synchronized (user.html) { System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); } System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }打印结果如下，可以看到 user 对象先是偏向锁，然后变为轻量级锁，最后走完同步块释放锁变为无锁状态。com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 05 38 3a 1c (00000101 00111000 00111010 00011100.html) (473577477.html) 4 4 (object header.html) 7d 7f 00 00 (01111101 01111111 00000000 00000000.html) (32637.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) d8 a9 de 25 (11011000 10101001 11011110 00100101.html) (635349464.html) 4 4 (object header.html) 7d 7f 00 00 (01111101 01111111 00000000 00000000.html) (32637.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total2. 关闭偏向锁如果通过参数设置 JVM 不开启偏向锁，那么新创建的对象是 001 无锁状态，遇到 synchronized 同步块会变为轻量级锁，遇到锁竞争变为重量级锁。@Test public void lightWeightToheavyWeightLock() { //-XX:-UseBiasedLocking 关闭偏向锁 User user = new User(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t1 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(5000.html);// 睡眠，创造竞争条件 } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t1.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); Thread t2 = new Thread((.html) -> { synchronized (user.html) { try { Thread.sleep(1000.html); } catch (InterruptedException e.html) { e.printStackTrace(); } } }); t2.start(); System.out.println(ClassLayout.parseInstance(user.html).toPrintable()); }com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 01 00 00 00 (00000001 00000000 00000000 00000000.html) (1.html) 4 4 (object header.html) 00 00 00 00 (00000000 00000000 00000000 00000000.html) (0.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 20 e9 ff 17 (00100000 11101001 11111111 00010111.html) (402647328.html) 4 4 (object header.html) 8e 7f 00 00 (10001110 01111111 00000000 00000000.html) (32654.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total com.daicy.jvm.LockClient$User object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header.html) 1a 63 00 40 (00011010 01100011 00000000 01000000.html) (1073767194.html) 4 4 (object header.html) 8e 7f 00 00 (10001110 01111111 00000000 00000000.html) (32654.html) 8 4 (object header.html) 24 f3 00 f8 (00100100 11110011 00000000 11111000.html) (-134155484.html) 12 1 byte User.age 0 13 3 (alignment/padding gap.html) 16 4 java.lang.String User.name null 20 4 (loss due to the next object alignment.html) Instance size: 24 bytes Space losses: 3 bytes internal + 4 bytes external = 7 bytes total关闭偏向的锁膨胀草图四、重量级锁原理Java 中 synchronized 的重量级锁，是基于进入和退出 Monitor 对象实现的。在编译时会将同步块的开始位置插入 monitorenter 指令，在结束位置插入 monitorexit 指令。当线程执行到 monitorenter 指令时，会尝试获取对象所对应的 Monitor 所有权，如果获取到了，即获取到了锁，会在 Monitor 的 owner 中存放当前线程的 ID，这样它将处于锁定状态，除非退出同步块，否则其他线程无法获取到这个 Monitor。测试代码：https://github.com/daichangya/Dtutorials/tree/main/jvm/src/main/java/com/daicy/jvm说明：本文内容基于 HotSpot JVM 早期版本（Java 14 及之前），其中偏向锁（Biased Locking）在 Java 15 中被 deprecated，并在 Java 16 中正式移除。在新版本 JVM 中，锁升级过程可能不再包含偏向锁阶段。
背景知识在介绍双亲委派机制之前，首先需要了解 ClassLoader（类加载器） 的相关概念。Java 程序运行在 Java 虚拟机（JVM）之中。我们在 IDE 中编写的 Java 源代码被编译器编译成 .class 字节码文件，随后由 ClassLoader 负责将这些 class 文件加载到 JVM 中执行。JVM 中主要提供了三层 ClassLoader：Bootstrap ClassLoader：主要负责加载核心类库（如 java.lang.* 等），并负责构造 ExtClassLoader 和 AppClassLoader。ExtClassLoader：主要负责加载 jre/lib/ext 目录下的一些扩展 jar 包。AppClassLoader：主要负责加载应用程序的主函数类及classpath 下的类。那么，一个由 Hello.java 编译成的 Hello.class 文件，究竟是如何被加载到 JVM 中的呢？双亲委派机制详解查看 JDK 源码中 java.lang 包下的 ClassLoader 类，重点关注其 loadClass 方法，核心逻辑如下：public Class<?> loadClass(String name.html) throws ClassNotFoundException { return loadClass(name, false.html); } // ----- 核心逻辑 ----- protected Class<?> loadClass(String name, boolean resolve.html) throws ClassNotFoundException { // 首先，检查是否已经被类加载器加载过 Class<?> c = findLoadedClass(name.html); if (c == null.html) { try { // 存在父加载器，递归地交由父加载器处理 if (parent != null.html) { c = parent.loadClass(name, false.html); } else { // 直到最上面的 Bootstrap 类加载器 c = findBootstrapClassOrNull(name.html); } } catch (ClassNotFoundException e.html) { // 如果父加载器无法找到该类，则捕获异常 // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null.html) { // 如果仍未找到，则调用 findClass 方法尝试自己加载 c = findClass(name.html); } } return c; }这段代码清晰地解释了双亲委派机制。为了更直观地理解，我们可以通过下图来描述上述代码的执行流程：结合上图与源码，当一个类（如 Hello.class）需要被加载时，流程如下：检查是否已加载：首先会在当前的 ClassLoader（例如 AppClassLoader）中检查该类是否已经被加载过，如果有则无需再次加载。委托父加载器：如果没有加载过，则获取父加载器，调用父加载器的 loadClass 方法。递归向上：父加载器同理，先检查自己是否加载过，如果没有则继续向上委托。这是一个类似递归的过程，直到到达 Bootstrap ClassLoader。向下查找：在到达 Bootstrap ClassLoader 之前，各级加载器都只是检查是否加载过，并不会尝试自己去加载。直到 Bootstrap ClassLoader（它没有父加载器），如果它无法加载，则会下沉到子加载器去尝试加载（调用 findClass），一直到最底层。抛出异常：如果没有任何加载器能加载该类，最终将抛出 ClassNotFoundException。机制设计的意义双亲委派机制的设计主要有以下好处：防止核心类库被篡改。例如，如果有人试图替换系统级别的类（如 String.java）并篡改其实现，在这种机制下，由于系统类已经被 Bootstrap ClassLoader 加载过了（当一个类需要加载时，最先尝试加载的就是 Bootstrap ClassLoader），其他类加载器并没有机会再去加载这些类。这在一定程度上防止了危险代码的植入，保证了 Java 核心 API 的安全性。参考文章https://tushu.info/archives/java-de-classloader-yu-package-ji-zhi.html> 说明：本文基于 Java 8 及更早版本的 ClassLoader 架构（Bootstrap/Ext/App）进行描述。Java 9 引入模块系统后，类加载器层级有所调整（如引入 Platform ClassLoader 等），但双亲委派的核心思想依然适用。
前提概要什么是 MVCC什么是当前读和快照读当前读、快照读和 MVCC 的关系MVCC 实现原理隐式字段Undo 日志Read View（读视图）整体流程MVCC 相关问题RR 是如何在 RC 级的基础上解决不可重复读的RC、RR 级别下的 InnoDB 快照读有什么不同前提概要什么是 MVCC？MVCC（Multi-Version Concurrency Control，多版本并发控制）是一种并发控制的方法，一般在数据库管理系统中实现对数据库的并发访问，或在编程语言中实现事务内存。MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能，用更好的方式去处理读 - 写冲突。其核心目标是做到即使存在读写冲突时，也能实现不加锁、非阻塞的并发读。什么是当前读和快照读？在学习 MVCC 多版本并发控制之前，必须先了解 MySQL InnoDB 下的当前读和快照读。当前读（Current Read）像 SELECT ... LOCK IN SHARE MODE（共享锁）、SELECT ... FOR UPDATE、UPDATE、INSERT、DELETE（排他锁）这些操作都属于当前读。特点：读取的是记录的最新版本。机制：读取时还要保证其他并发事务不能修改当前记录，因此会对读取的记录进行加锁。快照读（Snapshot Read）像不加锁的 SELECT 操作就是快照读，即不加锁的非阻塞读。前提：隔离级别不是串行级别（Serializable）。串行级别下的快照读会退化成当前读。目的：基于提高并发性能的考虑。实现：基于多版本并发控制（MVCC）。可以认为 MVCC 是行锁的一个变种，它在很多情况下避免了加锁操作，降低了开销。版本：既然是基于多版本，快照读可能读到的并不一定是数据的最新版本，而有可能是之前的历史版本。简而言之，MVCC 就是为了实现读 - 写冲突不加锁，而这个“读”指的就是快照读，而非当前读。当前读实际上是一种加锁的操作，是悲观锁的实现。当前读、快照读和 MVCC 的关系概念层面：准确地说，MVCC 多版本并发控制指的是“维持一个数据的多个版本，使得读写操作没有冲突”这么一个概念，仅仅是一个理想模型。实现层面：在 MySQL 中，实现这么一个 MVCC 理想模型，需要具体的功能支持。快照读就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。而相对而言，当前读就是悲观锁的具体功能实现。底层细节：快照读本身也是一个抽象概念。MVCC 模型在 MySQL 中的具体实现则是由 3 个隐式字段、Undo 日志、Read View 等去完成的（具体原理见下文）。MVCC 能解决什么问题，好处是？数据库并发场景主要有三种：读 - 读：不存在任何问题，也不需要并发控制。读 - 写：有线程安全问题，可能会造成事务隔离性问题，可能遇到脏读、幻读、不可重复读。写 - 写：有线程安全问题，可能会存在更新丢失问题，比如第一类更新丢失、第二类更新丢失。MVCC 带来的好处多版本并发控制（MVCC）是一种用来解决读 - 写冲突的无锁并发控制。它为事务分配单向增长的时间戳，为每个修改保存一个版本，版本与事务时间戳关联，读操作只读该事务开始前的数据库快照。所以 MVCC 可以为数据库解决以下问题：在并发读写数据库时，可以做到在读操作时不用阻塞写操作，写操作也不用阻塞读操作，提高了数据库并发读写的性能。同时还可以解决脏读、幻读、不可重复读等事务隔离问题（但不能解决更新丢失问题）。小结总之，MVCC 是因为设计者不满意只让数据库采用悲观锁这样性能不佳的形式去解决读 - 写冲突问题，而提出的解决方案。在数据库中，因为有了 MVCC，我们可以形成两个组合：MVCC + 悲观锁：MVCC 解决读写冲突，悲观锁解决写写冲突。MVCC + 乐观锁：MVCC 解决读写冲突，乐观锁解决写写冲突。这种组合的方式就可以最大程度地提高数据库并发性能，并解决读写冲突和写写冲突导致的问题。MVCC 实现原理MVCC 的目的就是多版本并发控制，在数据库中的实现，就是为了解决读写冲突。它的实现原理主要是依赖记录中的 3 个隐式字段、Undo 日志、Read View 来实现的。隐式字段每行记录除了我们自定义的字段外，还有数据库隐式定义的 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID 等字段。DB_TRX_ID：6 byte，最近修改（修改/插入）事务 ID。记录创建这条记录或最后一次修改该记录的事务 ID。DB_ROLL_PTR：7 byte，回滚指针。指向这条记录的上一个版本（存储于 rollback segment 里）。DB_ROW_ID：6 byte，隐含的自增 ID（隐藏主键）。如果数据表没有主键，InnoDB 会自动以 DB_ROW_ID 产生一个聚簇索引。删除标记：实际还有一个删除 flag 隐藏字段。记录被更新或删除并不代表真的删除，而是删除 flag 变了。如上图，DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键，DB_TRX_ID 是当前操作该记录的事务 ID，而 DB_ROLL_PTR 是一个回滚指针，用于配合 Undo 日志，指向上一个旧版本。Undo 日志Undo Log 主要分为两种：Insert Undo Log：代表事务在 INSERT 新记录时产生的 Undo Log。只在事务回滚时需要，并且在事务提交后可以被立即丢弃。Update Undo Log：事务在进行 UPDATE 或 DELETE 时产生的 Undo Log。不仅在事务回滚时需要，在快照读时也需要。所以不能随便删除，只有在快速读或事务回滚不涉及该日志时，对应的日志才会被 purge 线程统一清除。关于 Purge 线程从前面的分析可以看出，为了实现 InnoDB 的 MVCC 机制，更新或者删除操作都只是设置一下老记录的 deleted_bit，并不真正将过时的记录删除。为了节省磁盘空间，InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作，purge 线程自己也维护了一个 Read View（这个 Read View 相当于系统中最老活跃事务的 Read View）。如果某个记录的 deleted_bit 为 true，并且 DB_TRX_ID 相对于 purge 线程的 Read View 可见，那么这条记录一定是可以被安全清除的。对 MVCC 有帮助的实质是 Update Undo Log。Undo Log 实际上就是存在 rollback segment 中旧记录链，它的执行流程如下：比如有一个事务向 person 表插入了一条新记录，记录如下：name 为 Jerry，age 为 24 岁，隐式主键是 1，事务 ID 和回滚指针假设为 NULL。现在来了一个事务 1 对该记录的 name 做出了修改，改为 Tom。在事务 1 修改该行数据时，数据库会先对该行加排他锁。然后把该行数据拷贝到 Undo Log 中，作为旧记录（即在 Undo Log 中有当前行的拷贝副本）。拷贝完毕后，修改该行 name 为 Tom，并且修改隐藏字段的事务 ID 为当前事务 1 的 ID（默认从 1 开始，之后递增），回滚指针指向拷贝到 Undo Log 的副本记录（表示上一个版本就是它）。事务提交后，释放锁。又来了个事务 2 修改 person 表的同一个记录，将 age 修改为 30 岁。在事务 2 修改该行数据时，数据库也先为该行加锁。然后把该行数据拷贝到 Undo Log 中，作为旧记录。发现该行记录已经有 Undo Log 了，那么最新的旧数据作为链表的表头，插在该行记录的 Undo Log 最前面。修改该行 age 为 30 岁，并且修改隐藏字段的事务 ID 为当前事务 2 的 ID（即 2），回滚指针指向刚刚拷贝到 Undo Log 的副本记录。事务提交，释放锁。从上面可以看出，不同事务或者相同事务对同一记录的修改，会导致该记录的 Undo Log 成为一条记录版本线性表（即链表）。Undo Log 的链首就是最新的旧记录，链尾就是最早的旧记录。（当然，该 Undo Log 的节点可能是会被 purge 线程清除掉，向图中的第一条 Insert Undo Log，其实在事务提交之后可能就被删除丢失了，不过这里为了演示，所以还放在这里）。Read View（读视图）什么是 Read View？说白了，Read View 就是事务进行快照读操作的时候生成的读视图。在该事务执行的快照读的那一刻，会生成数据库系统当前的一个快照，记录并维护系统当前活跃事务的 ID（当每个事务开启时，都会被分配一个 ID，这个 ID 是递增的，所以最新的事务，ID 值越大）。所以我们知道 Read View 主要是用来做可见性判断的。即当我们某个事务执行快照读的时候，对该记录创建一个 Read View 读视图，把它比作条件用来判断当前事务能够看到哪个版本的数据。既可能是当前最新的数据，也有可能是该行记录的 Undo Log 里面的某个版本的数据。Read View 遵循一个可见性算法，主要是将要被修改的数据的最新记录中的 DB_TRX_ID（即当前事务 ID）取出来，与系统当前其他活跃事务的 ID 去对比（由 Read View 维护）。如果 DB_TRX_ID 跟 Read View 的属性做了某些比较，不符合可见性，那就通过 DB_ROLL_PTR 回滚指针去取出 Undo Log 中的 DB_TRX_ID 再比较。即遍历链表的 DB_TRX_ID（从链首到链尾，即从最近的一次修改查起），直到找到满足特定条件的 DB_TRX_ID，那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。那么这个判断条件是什么呢？如上，它是一段 MySQL 判断可见性的一段源码，即 changes_visible 方法（不完全，但能看出大致逻辑）。该方法展示了我们拿 DB_TRX_ID 去跟 Read View 某些属性进行怎么样的比较。在展示之前，我先简化一下 Read View。我们可以把 Read View 简单地理解成有三个全局属性：trx_list：一个数值列表，用来维护 Read View 生成时刻系统正活跃的事务 ID。up_limit_id：记录 trx_list 列表中事务 ID 最小的 ID。low_limit_id：Read View 生成时刻系统尚未分配的下一个事务 ID，也就是目前已出现过的事务 ID 的最大值 + 1。可见性判断逻辑如下：首先比较 DB_TRX_ID < up_limit_id。如果小于，则当前事务能看到 DB_TRX_ID 所在的记录；如果大于等于进入下一个判断。接下来判断 DB_TRX_ID >= low_limit_id。如果大于等于，则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的，那对当前事务肯定不可见；如果小于则进入下一个判断。判断 DB_TRX_ID 是否在活跃事务之中，trx_list.contains(DB_TRX_ID.html)。如果在，则代表在 Read View 生成时刻，你这个事务还在活跃，还没有 Commit，你修改的数据，我当前事务也是看不见的。如果不在，则说明，你这个事务在 Read View 生成之前就已经 Commit 了，你修改的结果，我当前事务是能看见的。整体流程在了解了隐式字段、Undo Log 以及 Read View 的概念之后，就可以来看看 MVCC 实现的整体流程了。我们可以模拟一下：当事务 2 对某行数据执行了快照读，数据库为该行数据生成一个 Read View 读视图。假设当前事务 ID 为 2，此时还有事务 1 和事务 3 在活跃中，事务 4 在事务 2 快照读前一刻提交更新了。所以 Read View 记录了系统当前活跃事务 1、3 的 ID，维护在一个列表上，假设我们称为 trx_list。Read View 不仅仅会通过一个列表 trx_list 来维护事务 2 执行快照读那刻系统正活跃的事务 ID，还会有两个属性 up_limit_id（记录 trx_list 列表中事务 ID 最小的 ID），low_limit_id（记录 trx_list 列表中事务 ID 最大的 ID，也有人说快照读那刻系统尚未分配的下一个事务 ID，也就是目前已出现过的事务 ID 的最大值 + 1，我更倾向于后者）。在这里例子中 up_limit_id 就是 1。low_limit_id 就是 4 + 1 = 5。trx_list 集合的值是 1, 3。Read View 如下图：我们的例子中，只有事务 4 修改过该行记录，并在事务 2 执行快照读前，就提交了事务。所以当前该行当前数据的 Undo Log 如下图所示。我们的事务 2 在快照读该行记录的时候，就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id、low_limit_id 和活跃事务 ID 列表 (trx_list.html) 进行比较，判断当前事务 2 能看到该记录的版本是哪个。所以先拿该记录 DB_TRX_ID 字段记录的事务 ID 4 去跟 Read View 的 up_limit_id 比较，看 4 是否小于 up_limit_id(1.html)，所以不符合条件。继续判断 4 是否大于等于 low_limit_id(5.html)，也不符合条件。最后判断 4 是否处于 trx_list 中的活跃事务，最后发现事务 ID 为 4 的事务不在当前活跃事务列表中，符合可见性条件。所以事务 4 修改后提交的最新结果对事务 2 快照读时是可见的。事务 2 能读到的最新数据记录是事务 4 所提交的版本，而事务 4 提交的版本也是全局角度上最新的版本。也正是 Read View 生成时机的不同，从而造成 RC、RR 级别下快照读的结果的不同。MVCC 相关问题RR 是如何在 RC 级的基础上解决不可重复读的？当前读和快照读在 RR 级别下的区别：表 1:表 2:而在表 2 这里的顺序中，事务 B 在事务 A 提交后的快照读和当前读都是实时的新数据 400，这是为什么呢？这里与上表的唯一区别仅仅是表 1 的事务 B 在事务 A 修改金额前快照读过一次金额数据，而表 2 的事务 B 在事务 A 修改金额前没有进行过快照读。所以我们知道事务中快照读的结果是非常依赖该事务首次出现快照读的地方。即某个事务中首次出现快照读的地方非常关键，它有决定该事务后续快照读结果的能力。我们这里测试的是更新，同时删除和更新也是一样的。如果事务 B 的快照读是在事务 A 操作之后进行的，事务 B 的快照读也是能读取到最新的数据的。RC、RR 级别下的 InnoDB 快照读有什么不同？正是 Read View 生成时机的不同，从而造成 RC、RR 级别下快照读的结果的不同。RR（Repeatable Read）级别：在某个事务对某条记录的第一次快照读会创建一个快照及 Read View，将当前系统活跃的其他事务记录起来。此后在调用快照读的时候，还是使用的是同一个 Read View。所以只要当前事务在其他事务提交更新之前使用过快照读，那么之后的快照读使用的都是同一个 Read View，所以对之后的修改不可见。即 RR 级别下，快照读生成 Read View 时，Read View 会记录此时所有其他活动事务的快照，这些事务的修改对于当前事务都是不可见的。而早于 Read View 创建的事务所做的修改均是可见。RC（Read Committed）级别：在事务中，每次快照读都会新生成一个快照和 Read View。这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。总之，在 RC 隔离级别下，是每个快照读都会生成并获取最新的 Read View；而在 RR 隔离级别下，则是同一个事务中的第一个快照读才会创建 Read View，之后的快照读获取的都是同一个 Read View。说明：本文内容基于 MySQL InnoDB 存储引擎的通用实现原理（适用于 MySQL 5.7/8.0 等常见版本）。不同版本在具体实现细节上可能略有差异，但核心 MVCC 机制保持一致。
前言近日，笔者阅读了硅谷招聘经理 Steve Buckley 的一篇文章，该文从应聘者的视角探讨了面试中的同一话题：如何向公司提问。许多求职者往往将面试视为一种单向选择，事实上，面试是一种双向选择：不仅是公司挑选你，也是你挑选公司。面试旨在为双方提供互相了解的机会——公司在评估你，你也在评估公司。面试官通常对此心知肚明，所以他们有心理准备期待你提出问题，并会做出回答。因此，面试时切勿浪费向公司提问的机会。主动提问不仅表明你比较重视这个职位，还能加深面试官对你的印象，可能会提高面试的成功率。面试提问的注意事项在提问环节，以下几点注意事项值得参考：事前准备：面试之前，一定要做准备，多了解公司的情况。围绕核心：你提出的问题，应该围绕“这份工作是否合适我”这个中心点，其他与应聘关系不大的问题，不宜多问。自然放松：提问的时候，要自然放松，不要害羞，就把它当作普通的聊天。你要表现出对公司的真诚兴趣。直截了当：提问要直截了当，不要绕圈子。提出问题之后，你要保持安静，让面试官多说话。专注倾听：面试官回答的时候，你可以做笔记，或者事先询问能不能做。笔记必须简短，你的大部分时间，要用来全神贯注倾听面试官的回答，并与其有眼神的交流。后续跟进：面试结束后一周内，最好打一个电话或发一封邮件，了解公司对你的反馈意见。即使面试失败，你不妨也问一下原因，这会有助于你以后的面试。典型问题示例下面是一些你可以问的典型问题，供参考。问题一：你们为什么要招聘这个职位？Q1: Why are you currently recruiting for this position?这个问题会使得面试官开始谈论当前的项目，或者谈论前一位离职人员。无论哪种情况，都会让你了解一些与你最密切相关的公司情况。问题二：你们的新员工多吗？Q2: Do you have many new staffs?这个问题起一个过渡作用，使得谈话导向公司内部的情况。但是，它本身也能说明一些问题。如果公司成立已经超过四年，又没有新项目，但是新员工却很多，这往往说明公司文化不是很健康。问题三：你们公司（团队）目前面临的最大挑战是什么？Q3: What are the biggest challenges your team are facing right now?如果面试官开始谈论一些具体的技术问题，这很好；如果他的回答是项目时间紧迫，或者需要更多的资金，那你就要小心一点了，公司管理上面可能有问题。问题四：什么新技术（编程语言）是你们未来希望采用的？Q4: What technologies/languages would you like to see your team adapt to that aren't currently being utilised?如果你申请的是技术职位，面试官恰巧又是技术负责人，那么这个问题将会非常合适。你会对公司的技术路线有所了解和准备，一旦入职，就能更好地适应公司的需要。问题五：在业务方面，有没有什么地方是你们不满意的，未来想要改进的？Q5: Few companies, if any, are 100% satisfied with the way their business is operating. If you could simply flick a switch to fix it, what one thing would you change?很少有公司会百分之百满意自身的现状，即使那些状况很良好的公司也是如此。这个问题可以让你对公司管理层的关注重点和担忧之处，有所了解。问题六：我申请的这个职位，对公司的业务有何影响？Q6: If you struggle to fill the position I have applied for, what impact would that have on the business?这个问题会让你了解自己在公司的角色，以及你的岗位对公司是否重要。说明本文引用的原始文章及图片资源发布于 2011-2012 年间。虽然面试的具体形式和技术栈可能随时代演变，但文中关于双向选择、提问策略及沟通心态的核心建议依然具有参考价值。
订单表的分库分表方案设计(大数据.html)
订单表的分库分表方案设计在业界的大数据场景下，订单表的分库分表（Sharding）设计通常主要有两类思路：按照订单号切分或按照用户 ID 切分。以下是对这两种方案的详细分析、优缺点对比以及常见问题的解决方案。主流分库分表方案方案一：按照订单号 Hash 分散该方案将订单号视为字符串，通过 Hash 算法将数据分散到多个数据库服务器中。路由规则：具体存储到哪个库、哪张表，通常由订单号中的数字特征决定。典型案例：例如微信红包的订单分库分表，是对订单号进行 Hash 计算（非简单的取模或取整），以实现数据的均匀分散。订单号末尾的 3 位数字往往包含了库名和表名的信息。查询挑战：如果需要查询某用户的所有订单，由于数据是根据订单号分散的，该用户的订单可能分布在多个库、多张表中。若全库全表扫描，效率极低。对比：即便是按照用户 ID（UID）切分，查询特定订单号时也会面临类似问题（不知道数据在哪个库）。方案二：按照用户 ID 分散以用户 ID（UID）作为切分键，业界使用较为普遍。主要有两种思路：范围切分：例如 0-2000 万 UID 对应库 A，2000 万 -4000 万 UID 对应库 B。缺点：容易出现数据热点。若某个范围内的用户下单量特别大，会导致对应库压力过高，而其他库闲置。取模运算（推荐）：使用 UID % N 的方式。优点：处理简单，数据能均匀分散到多个库，不易出现单库性能瓶颈。缺点：扩容麻烦。扩容通常需要进行数据迁移。扩容策略：为了减少迁移量，一般以倍数形式增加库数量（例如从 8 库扩容到 16 库，再到 32 库）。这种方式虽不能彻底避免迁移，但能显著减少迁移数据量，且一次扩容可支撑较长时间的业务增长。基于用户 ID 分片的详细设计以下以“按照用户 ID 取模”为例，分析具体的库表定位规则。1. 路由算法逻辑假设使用用户 ID 末尾 4 位数字进行计算：库名称定位：用户 ID 末尾 4 位 % 32Mod 表示取余数。例如 15 % 4 = 3。余数范围 0-31，对应 32 个数据库实例。库名示例：order_db_0 至 order_db_31。表名称定位：(用户 ID 末尾 4 位 / 32.html) % 32Dev 表示取整数商（向下取整）。例如 15 / 4 = 3.75，取整为 3。代码表示：floor(15/4.html)。表名示例：order_tb_0 至 order_tb_31。每个库内包含 32 张表。2. 容量估算按照上述规则：32 个库 * 32 张表 = 1024 张表。若单表数据量控制在 1000 万至 5000 万行，理论上可支撑海量数据。若希望表数量更少，可调整取模的基数（如将 32 改小）。查询场景与挑战方案一的查询问题若按照订单号分散数据，查询某用户的所有订单时，必须进行跨库、跨表查询，效率低下，通常不可行。方案二的查询问题若按照用户 ID 分散数据，虽然查询用户订单列表很方便，但在实际应用中会面临以下高频查询需求：按订单号查询：前台或后台输入订单号查询详情（Select）。按订单号更新：修改订单状态（Update）。由于数据是按用户 ID 分散的，仅凭订单号无法直接定位到具体的库和表。若全库扫描，性能无法接受。解决方案：建立索引映射表没有一种技术方案是十全十美的。针对“按用户 ID 分片后无法通过订单号查询”的问题，常见的解决思路是建立订单号与用户 ID 的索引关系表。1. 核心思路映射表设计：建立一张 order_user_idx 表，存储两项核心数据：订单号 、 用户 ID。查询流程：输入订单号，查询索引表获取用户 ID。根据用户 ID 利用路由算法定位到具体的库和表。查询订单详情。写入流程：创建订单时，需同步插入关系记录。为降低用户等待时间，该操作可异步化（如加入消息队列处理）。2. 索引表的性能优化随着时间推移，一个用户的订单量可能达到几百甚至更多，索引表本身也可能成为瓶颈。索引表分片：order_user_idx 表本身也需要分库分表。建议直接根据订单号取模进行分片。缓存策略：订单与用户的绑定关系创建后通常不会变更。可将该关系表放入内存缓存（如 Redis）。查询时优先走缓存，未命中再查数据库，以提升速度。复杂业务场景思考思考一：B2B 平台的分片键选择在 B2C 平台，卖家通常是平台自身。但在 B2B 平台，买家和卖家均需查看订单。按买家 ID 分片：卖家查询自己的所有订单时，因商品被多个买家购买，订单分散在不同库表，导致跨库查询性能低下。按卖家 ID 分片：买家在多个店铺下单，订单同样分散，买家查询自身订单时性能低下。行业实践（如淘宝）：采用数据冗余方案，拆分买家库和卖家库。买家库：按买家 ID 分库分表。卖家库：按卖家 ID 分库分表。同步机制：下单时写两份数据。先写入买家库，再通过消息中间件异步同步到卖家库。状态变更时同样通过异步消息通知对方库更新。思考二：订单号中嵌入分片信息是否可以直接按订单号分库分表？若订单分散在不同库表，用户查询历史订单列表（分页）会非常麻烦。优化方案：在生成订单号时嵌入分片信息。案例：淘宝订单号中包含卖家 ID 末 2 位、买家 ID 末 2 位。微信红包订单号末尾 3 位包含库表信息。优势：解析订单号即可直接定位库表，无需额外计算或查表。实现：若按用户 ID 后 4 位取模分片，可在订单号生成时追加用户 ID 后 4 位。B2B 场景下可同时包含买卖双方 ID 特征（需注意订单号长度）。思考三：按时间分表例如每月一张表或每年一张表。业界较少单独使用此方案切分订单数据，主要存在以下问题：查询困难：查询用户所有订单需跨库跨表。折中方案是限制查询范围（如仅查近一年）。写入热点：若按月分表，当月订单量暴涨会导致单表写入瓶颈，影响整个系统交易。原则：优秀的分表方案应尽量将写入数据分散到多个表，以达到分流效果，提高系统并发能力。总结与建议水平分库分表会显著增加开发和维护工作量。切分后会产生新的查询问题，需要额外的代码逻辑和中间表支持。建议：若业务未达到瓶颈，尽量不要过早进行水平分表。优先通过提升硬件配置（垂直扩展）扛住压力。现状：许多大型企业（如银行、运营商）早期使用高性能 Oracle 搭配小型机服务器，单表数据量可达十多亿。小型机性能强劲但成本高昂。重构时机：当交易量达到上亿规模，且公司具备足够经济实力时，再聘请经验丰富的技术团队进行架构重构。说明：本文提到的“小型机 + Oracle"方案属于传统集中式架构的典型代表。随着云原生技术的发展，现代架构更多倾向于使用分布式数据库（如 TiDB、OceanBase）或云厂商提供的分库分表中间件（如 ShardingSphere）来解决海量数据存储问题，成本与扩展性更具优势。
面试问题总结 021. 请简单介绍一下你在 Netty 方面的项目经验我曾开发了一套基于 Netty 的 Web 容器，Netty 在该项目中发挥了核心作用。具体实践如下：解码与编码机制：利用 Netty 的 ChannelPipeline 机制，我们设计了一系列解码器（Decoder）和编码器（Encoder），实现了对不同数据格式的高效处理。例如，针对二进制数据进行了快速的反序列化和序列化操作，确保了数据在传输过程中的准确性与完整性。数据安全与校验：通过添加自定义过滤器（Filter），实现了对数据的实时校验和封装，显著提升了数据的安全性和可靠性。高并发处理能力：得益于 Netty 高性能的异步非阻塞 I/O（Async Non-blocking I/O）模型，系统能够轻松应对数以万计的并发连接，大幅提升了吞吐量和响应速度。在压力测试中，即使在高并发场景下，系统依然保持稳定，响应时间控制在极低范围内。可扩展性与定制开发：利用 Netty 良好的可扩展性，我们根据项目特定需求进行了定制化开发。例如，开发了专门的业务处理器（Handler）来处理特定类型的业务逻辑，进一步提高了系统的灵活性和可维护性。Netty 的使用不仅解决了项目中的实际技术难题，也为未来的业务扩展奠定了坚实基础。2. 请说说你在使用 Spring Boot 时，遇到过哪些挑战，以及你是如何解决的？在使用 Spring Boot 的过程中，我主要遇到了以下挑战并逐一解决：自动配置原理的理解挑战：初期阶段，Spring Boot 的自动配置（Auto Configuration）和自动注解功能虽然提高了开发效率，但其背后的原理令人困惑。解决：我深入研究了 Spring Boot 源代码，分析其实现机制。发现 Spring Boot 通过巧妙使用条件注解（Conditional Annotations）和配置文件，根据环境和依赖自动调整配置。同时，通过查阅文档和实际项目演练，我逐渐掌握了各种注解的用法，能够熟练运用它们简化开发过程。内嵌容器的替换（Tomcat 转 Netty）挑战：在深入使用阶段，我需要将内嵌的 Tomcat 容器替换为 Netty 容器。这需要深入理解 Spring Boot 的容器启动机制和 Netty 的工作原理，具有一定难度。解决：查阅资料了解到 Spring Boot 通过自动配置类启动内嵌容器。实现了一个自定义的服务器启动类，并将其配置到 Spring Boot 的自动配置体系中。研究 Netty 文档和示例代码，实现基于 Netty 的服务器，并通过实现 Spring Boot 相关接口和配置类将其整合到项目中。在过程中解决了配置参数调整、线程模型优化等问题，最终成功替换容器，满足了项目的特定需求。通过这些挑战的解决，我对 Spring Boot 的理解更加深入，技术水平和解决问题的能力也得到了提升。3. 请谈谈你对微服务架构的理解，以及在实际项目中如何运用 Spring Boot 来实现微服务对微服务架构的理解微服务架构代表了一种现代化的软件架构理念，它将原本庞大复杂的单体应用拆分为多个独立运行、功能单一的小型服务。其核心优势包括：技术选型灵活：每个微服务可根据业务需求选择最适合的技术栈（如 Java、Python 等），充分发挥不同技术的优势。独立部署与扩展：各个微服务可独立部署，更新互不影响；同时可根据负载情况单独扩展，提高资源利用率。易于维护与故障隔离：某个微服务出现问题时，只会影响该服务自身，不会导致整个系统崩溃，便于快速定位和解决问题。基于 Spring Boot 的微服务实现在实际项目中，我们通常结合 Spring Cloud 框架来实现微服务架构：服务注册与发现：使用组件（如 Eureka 或 Consul），微服务启动时将信息注册到注册中心，其他服务可从中获取可用地址。新服务上线时无需更改配置即可被发现和调用。服务通信与负载均衡：服务间通信可采用 RESTful API 或消息队列。利用 Spring Cloud 的 Feign 客户端可方便地进行服务调用并实现负载均衡。容错处理：利用 Hystrix 进行服务熔断和降级，当某个服务故障时快速响应，避免故障扩散。配置管理：使用 Spring Cloud Config 实现集中式配置管理，微服务可从配置中心获取信息，方便更新和维护。监控与管理：利用 Spring Cloud Admin 等功能，实时监控微服务的运行状态和性能指标，便于及时发现和处理问题。通过 Spring Boot 与 Spring Cloud 的结合，能够高效地构建、部署和管理微服务架构，为企业业务发展提供强大的技术支撑。4. 在微服务架构中，如何进行服务的监控和故障排查？在微服务架构中，有效的服务监控和故障排查至关重要，主要措施包括：指标监控与报警利用 Prometheus 收集监控指标，涵盖请求 QPS、响应时间、内存使用率、CPU 负载等关键数据。通过 Grafana 以直观图表展示，让运维和开发人员快速了解服务状态。设置智能报警机制，例如当请求 QPS 超过预设阈值（如 10000 QPS）或响应时间慢于特定值（如 1 秒）时，立即触发报警通知相关人员。日志管理采用 ELK 架构（Elasticsearch, Logstash, Kibana）进行日志文件收集。对日志中的异常信息（如特定错误关键字、Exception 等）进行实时监测和统计。例如，设定规则当最近 5 分钟内出现一定数量异常时自动触发报警。链路追踪（Tracing）引入全局 Trace ID，该 ID 在整个请求链路中贯穿始终（从客户端发起请求到微服务间调用，再到响应返回）。出现问题时，通过 Trace ID 可快速定位具体请求在哪个微服务出错，以及经过的路径，从而精准确定故障点。依赖关系分析充分利用注册服务中心，分析服务间调用关系并制作调用关系 Top 图。清晰了解每个服务的依赖关系，故障时能迅速判断受影响的服务范围，为排查和恢复提供支持。综合运用上述方法，能够在微服务架构中实现高效的服务监控和故障排查，确保系统稳定运行。5. 请说说在微服务架构中，如何保障数据的一致性在微服务架构中，保障数据一致性可从以下几个方面着手：最终一致性策略设置合理的重试机制：当微服务操作数据失败时，自动多次重试，确保在一定时间内尝试直至成功。制定数据回归策略：当重试多次仍无法成功时，安全地将数据恢复到之前的状态，避免不一致。全局事务管理引入专门的事务管理中间件，对多个微服务之间的事务进行统一协调。在事务开始时对所有涉及微服务进行预提交，确保各服务准备好处理。若执行过程中出现问题，则进行回滚操作。虽然实现复杂，但对一致性要求极高的场景非常有效。共享数据库（特定场景）如果对事务要求非常高，可让多个微服务同时访问一个数据库，利用数据库本身的事务机制保证一致性，避免服务间数据冲突。（注：此方案需权衡服务耦合度）数据库同步机制利用数据库日志监听技术（如 CDC），当一个数据库数据发生变化时，及时将变更同步到其他相关数据库，确保不同库之间的数据保持一致。通过综合运用这些方法，可以在微服务架构中有效地保障数据的一致性。说明：本文内容基于经典微服务技术栈整理。文中提到的 Hystrix 目前已进入维护模式，新项目建议考虑 Resilience4j 或 Spring Cloud Alibaba Sentinel；Eureka 同样建议结合 Nacos 等现代注册中心进行评估；Spring Cloud Admin 也可替换为更现代的监控方案（如 Spring Boot Admin 配合 Prometheus）。实际技术选型请参考最新官方文档及社区趋势。
请说一说你在使用 Grafana 和 SkyWalking 进行性能监控和追踪时，有哪些具体的实践经验和收获？Grafana 是一个功能强大的数据可视化工具。在实际项目中，我利用它连接多种数据源（如 MySQL、Redis 等），通过配置自定义仪表盘，能够直观地展示系统关键性能指标。例如，我设置了实时监控页面，展示服务器的 CPU、内存使用率以及数据库的连接数等指标。同时，利用其报警功能，当指标超过预设阈值时，能及时通过邮件和短信通知相关人员，大大提高了问题响应速度。SkyWalking 则在链路跟踪方面表现出色。通过它，我可以清晰地看到微服务架构中各个服务之间的调用关系和性能指标。在一次复杂的项目中，当系统出现性能问题时，借助 SkyWalking 的全局 Trace ID 和 Span ID，我快速定位到了问题服务和具体的代码位置。并且，它对各种中间件的拦截功能非常实用，比如对 Redis 和 HTTP 请求的拦截，让我能够深入了解这些关键组件的性能情况。通过对收集到的数据进行分析，我能够针对性地进行性能优化，极大地提升了系统的稳定性和响应速度。在进行数据库设计时，你通常会遵循哪些原则？请结合实际项目经验说明。在进行数据库设计时，我主要遵循以下几个原则：范式化原则严格遵循数据库范式，尤其是第三范式（3NF），确保数据的独立性和减少数据冗余。在我负责的一个大型企业资源管理系统项目中，将员工信息、部门信息、岗位信息分别存储在不同的表中，通过外键关联，避免了重复存储部门名称、岗位名称等信息，极大地提高了数据的一致性和可维护性。当部门名称或岗位名称发生变化时，只需在对应的表中进行修改，而不会影响到员工信息表。性能与反范式平衡原则虽然范式化可以提高数据的规范性，但在某些对性能要求极高的场景下，适当进行反范式设计。例如，在一个电商平台的订单系统中，订单表中会存储商品的名称和价格等信息，虽然这会造成一定的数据冗余，但在查询订单详情时，可以避免频繁的关联查询商品表，大大提高了查询性能。扩展性原则充分考虑未来业务的发展，预留扩展字段。在一个社交平台项目中，用户信息表中预留了几个自定义字段，随着平台功能的不断扩展，这些字段被用于存储用户的个性化标签、兴趣爱好等信息，无需对表结构进行大规模修改，保证了系统的稳定性。索引优化原则合理添加索引，包括普通索引、唯一索引和覆盖索引等。在一个金融交易系统中，对交易流水表的交易时间、交易金额等频繁用于查询和排序的字段添加索引，极大地提高了查询和统计的效率。同时，通过分析业务场景，创建了一些覆盖索引，使得查询可以直接从索引中获取所需数据，无需回表查询，进一步提升了性能。数据校验规则原则设置严格的数据校验规则，确保存入数据库的数据符合规范。在一个医疗管理系统中，对患者年龄字段设置了 0 到 120 的范围限制，对药品剂量字段设置了合理的数值范围和精度要求，有效避免了错误数据的存入。数据安全性原则高度重视数据安全，通过多种方式进行保护。在一个政府部门的信息管理系统中，对敏感数据进行加密存储，同时创建视图和存储过程，严格控制不同用户对数据的访问权限。例如，普通用户只能通过视图查询部分公开信息，而管理员可以通过存储过程进行数据的修改和删除操作，确保了数据的安全性和保密性。总之，在数据库设计过程中，我始终综合考虑这些原则，结合实际项目需求，进行科学合理的设计，以确保数据库的高效、稳定和安全。在团队协作中，你认为最重要的品质是什么？请举例说明。在团队协作中，我认为最重要的品质是：目标一致、责任心强、善于沟通和懂得协作分工。目标一致是团队协作的基石。在一个软件开发项目中，我们的团队面临着紧张的工期和复杂的需求。但大家都清楚地认识到，我们的共同目标是按时交付高质量的软件产品，为客户创造价值。每个人都围绕这个目标努力，即使在遇到困难和分歧时，也始终以实现目标为导向进行决策和行动。比如在需求讨论中，当不同的意见出现时，大家不是固执己见，而是通过深入分析和讨论，找到最符合项目目标的解决方案。责任心强是团队成功的关键。在团队中，每个人都对自己的工作负责，认真对待每一个任务。有一次，一位同事负责的模块出现了一个难以察觉的小问题，但他没有忽视，而是花费大量时间进行排查和修复，确保整个系统的稳定运行。这种责任心不仅保证了自己负责的部分不出差错，也为其他成员提供了可靠的工作基础。善于沟通能让团队高效运转。在项目进行过程中，我们定期召开团队会议，分享工作进展、遇到的问题和解决方案。同时，在日常工作中，大家也随时进行沟通交流，确保信息畅通。比如在设计阶段，开发人员和设计师密切沟通，确保设计方案既满足用户需求又具有技术可行性。这种及时有效的沟通避免了误解和重复工作，提高了工作效率。懂得协作分工可以充分发挥团队成员的优势。我们根据每个人的技能和经验，合理分配任务。在一个大型项目中，技术能力强的成员负责核心模块的开发，细心的成员负责测试工作，善于沟通的成员负责与客户对接需求。通过这种分工协作，我们充分发挥了每个人的优势，提高了整个团队的工作质量和效率。总之，目标一致、责任心强、善于沟通和懂得协作分工这些品质在团队协作中至关重要，它们共同推动着团队不断前进，实现共同的目标。请描述一次你在项目中遇到的重大挑战，以及你是如何克服它的。在爱卡汽车网负责广告部门项目时，我遭遇了一系列极具挑战性的情况：角色转变：我从单纯的研发人员跃升为技术经理和架构师，这不仅要求我具备卓越的技术能力，还需拥有出色的领导和管理才能。业务陌生：对广告业务的陌生犹如一座巨大的山峰横亘在面前，不知从何处着手进行架构设计以满足业务需求。技术转型：公司决定从以 P2P 为主的技术体系全面转型为 Java，这意味着要从无到有搭建完整的 Java 开发体系，涵盖开发、测试、发布监控等各个环节，还涉及 Docker、Kafka 等之前未曾深入接触的技术。架构复杂度：采用微服务架构加大了项目难度，包括广告管理系统、先进的广告搜索引擎、离线和在线的广告投放效果统计等多个复杂部分。例如，广告搜索引擎需要具备快速准确为用户提供相关广告内容的能力；广告投放效果统计既要利用 Spark、Hadoop 进行离线统计，又要借助 Redis 进行在线统计。此外，服务发现注册和配置中心、定时任务管理系统以及外部广告投放开放平台等方面的建设也充满挑战。面对这些挑战，我采取了一系列果断而有效的措施：业务攻坚：对于广告业务的陌生，我投入大量时间和精力，查阅行业资料、研究竞争对手的做法，并积极与业务部门同事沟通交流，深入了解广告业务的重要名词、关键环节和业务流程。通过不断学习和实践，我逐渐掌握了广告业务的核心要点，为架构设计奠定了坚实的基础。技术体系建设：在技术转型方面，我带领团队系统地学习 Java 技术栈，从基础语法到高级特性，从框架应用到性能优化，逐步搭建起稳定高效的 Java 开发体系。架构设计与优化：对于微服务架构的实施，我仔细研究每个技术组件的特点和适用场景，精心设计系统架构，确保各个微服务之间的协同工作和高效运行。例如，在开发广告搜索引擎时，深入研究先进的搜索引擎技术，不断优化算法和查询性能；在消息同步机制的开发中，反复测试和优化 Redis 的发布订阅功能，确保数据的可靠传输和实时同步。通过不懈努力，我成功克服了项目中的重大挑战。这个项目充分展现了我的领导能力、架构设计能力、编程能力和强大的学习能力。我不仅带领团队按时交付了高质量的项目成果，还为公司的技术转型和业务发展做出了重要贡献。请简要介绍一下你在项目管理方面的经验。在项目管理中，我始终秉持着严谨与高效的原则：项目启动：以敏锐的洞察力全面剖析项目目标及相关方。与产品、业务等各方深入沟通，确保对项目需求有精准把握，对可能存在的模糊点绝不放过，反复确认产品文档的准确性与可行性。方案评估与计划：技术方案评估环节，我会召集技术骨干进行多轮研讨。从技术可行性、性能优化、未来扩展性等多个维度进行考量，确保方案既符合当下需求，又能为未来发展留有余地。随后，精心制定详细且具有弹性的项目进度计划，明确各阶段里程碑，精确到具体交付物与时间节点。人员管理：通过日常观察、技术交流等方式深入了解团队成员的能力与特长。根据项目需求进行合理分工，让每个人都能在自己擅长的领域发挥最大价值。同时，注重团队协作氛围的营造，定期组织技术分享会和团队建设活动，提升团队凝聚力。过程沟通：项目推进过程中，我保持与上级领导的密切沟通。以清晰简洁的方式同步项目进展、风险点及解决方案，让领导对项目全局有准确掌控。验收与交付：验收阶段，我以严格的标准把控质量与时间。提前制定详细的验收计划，对每一个交付物进行细致检查。面对相关方提出的新需求，冷静分析其影响范围与紧急程度，通过合理的评估确定是否纳入后续版本，确保项目的稳定性与可持续性。复盘总结：项目上线后，我会立即组织全面总结。对项目过程中的人力问题、需求变更、进度偏差等进行深入剖析，总结经验教训，形成详细的文档记录，为下一个项目提供宝贵的参考依据。在你所负责的项目中，遇到过最棘手的技术难题是什么？你是如何解决的？在我负责的广告业务项目中，遇到过最棘手的技术难题当属跨领域的截屏需求。作为主要从事 Java 后端开发的我，面对记录每个页面投放广告效果并截屏的任务，挑战巨大。PC 端解决方案：首先在 PC 端展开攻坚。通过深入调研，发现测试工具中的浏览器操控测试工具，可打开固定网址、拉到指定位置并进行截屏操作，成功解决了 PC 端的难题。APP 端挑战：然而，APP 端的挑战接踵而至。对于 H5 页面，可通过网址调用指定页面进行截屏，但原生应用却困难重重。需配置脚本，通过一系列操作到达指定页面再截屏，可这种方式不仅配置麻烦，而且 APP 改版后可能因操作步骤变化导致效果不理想，维护也极为困难。尽管我们努力尝试多种解决方案，如不断优化脚本以适应可能的改版情况，积极探索更稳定的截屏机制，但最终仅成功交付了 PC 端，APP 端仅展示了 Demo 效果未上线。在解决这个问题的过程中，我深刻体会到跨领域知识的关键作用。它促使我在未来的项目中更加积极主动地学习不同领域的技术，拓宽自己的知识面。同时，也让我明白面对复杂问题时，要坚持不懈地进行调研和尝试不同的解决方案。不能因为困难就轻易放弃，而应勇于挑战，不断探索创新，以找到最适合项目需求的解决方案。通过这个项目，我不仅提升了自己的技术能力，还在跨领域协作和问题解决方面积累了宝贵经验，为今后的项目管理工作奠定了坚实基础。在你的项目经历中，有没有运用到一些新兴技术？如果有，是如何应用的？在我的项目经历中，最近引入了大模型（LLM） 这一热门的新技术。我深入了解了大模型的底层实现原理和应用。大模型能够将语言转换为特征向量，这些特征向量包含语言的情感、同义词等多维度信息，从而可以通过特征向量的比较，从较长的文本中找出相似的内容。在快递物流领域，我们成功地应用了大模型技术：应用场景：当使用三方公司发货时，客户会将物流公司和物流编码录入我们的系统，但我们常常无法确定其准确性。技术方案：利用大模型技术，我们可以根据快递单号进行快速匹配，确定该单号所属的快递公司，从而校验客户输入的快递公司和快递编号是否一致。如果通过快递编码无法找到对应的快递公司，那么很可能客户输入的快递公司信息是错误的，实现了二次校验，确保客户数据输入的准确性，对错误进行了前置校验。效果验证：我们进行了充分的前期调研和数据分析。将历史数据导入大模型中进行特征向量匹配，发现准确率达到了 95%，而原来人工校验的准确率只有 92%。这一显著的提升为我们引入该技术提供了有力的依据。风险控制：为了确保新功能不影响现有功能，我们采取了谨慎的措施。仅给客户提供提示，告知他们可能输入错误，既不影响系统的正常运行，又能让客户及时纠正错误，顺利完成业务流程。引入大模型技术后，我们不仅提高了数据校验的准确性，还大大节省了人力成本和时间成本。以往人工校验需要耗费大量的时间和精力，而现在借助大模型，效率得到了极大的提升。同时，这一新技术的应用也优化了业务流程，提高了客户满意度，为公司的快递物流业务带来了新的活力和竞争力。总之，引入大模型技术是一次非常成功的尝试，让我在实际项目中深刻体会到了新技术的强大力量和价值。说明：文中关于大模型（LLM）的应用案例基于特定业务场景（快递物流校验），实际准确率与效果可能因模型版本、数据质量及业务场景差异而有所不同。技术栈部分（如 Grafana、SkyWalking、Java 微服务）为通用实践，具体版本请参考当时项目环境。
前言Java 提供了种类丰富的锁机制，每种锁因其特性不同，在适当的场景下能够展现出极高的效率。本文旨在结合锁相关源码（本文源码基于 JDK 8 和 Netty 3.10.6）与使用场景进行举例，为读者介绍主流锁的知识点以及不同锁的适用场景。Java 中通常是按照是否含有某一特性来定义锁。我们通过特性将锁进行分组归类，再使用对比的方式进行介绍，帮助大家更快捷地理解相关知识。下面给出本文内容的总体分类目录：1. 乐观锁 VS 悲观锁乐观锁与悲观锁是一种广义上的概念，体现了看待线程同步的不同角度。在 Java 和数据库中都有此概念对应的实际应用。概念简述：悲观锁（Pessimistic Lock）：对于同一个数据的并发操作，悲观锁认为自己在使用数据的时候一定有别的线程来修改数据，因此在获取数据的时候会先加锁，确保数据不会被别的线程修改。Java 中，synchronized 关键字和 Lock 的实现类都是悲观锁。乐观锁（Optimistic Lock）：认为自己在使用数据时不会有别的线程修改数据，所以不会添加锁，只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新，当前线程将自己修改的数据成功写入；如果数据已经被其他线程更新，则根据不同的实现方式执行不同的操作（例如报错或者自动重试）。乐观锁在 Java 中是通过无锁编程来实现，最常采用的是 CAS（Compare And Swap） 算法，Java 原子类中的递增操作就通过 CAS 自旋实现的。根据上面的概念描述我们可以发现：悲观锁适合写操作多的场景，先加锁可以保证写操作时数据正确。乐观锁适合读操作多的场景，不加锁的特点能够使其读操作的性能大幅提升。光说概念有些抽象，我们来看下乐观锁和悲观锁的调用方式示例：// ------------------------- 悲观锁的调用方式 ------------------------- // synchronized public synchronized void testMethod() { // 操作同步资源 } // ReentrantLock private ReentrantLock lock = new ReentrantLock(); // 需要保证多个线程使用的是同一个锁 public void modifyPublicResources() { lock.lock(); try { // 操作同步资源 } finally { lock.unlock(); } } // ------------------------- 乐观锁的调用方式 ------------------------- private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个 AtomicInteger atomicInteger.incrementAndGet(); // 执行自增 1通过调用方式示例，我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源，而乐观锁则直接去操作同步资源。那么，为何乐观锁能够做到不锁定同步资源也可以正确地实现线程同步呢？我们通过介绍乐观锁的主要实现方式"CAS"的技术原理来为大家解惑。CAS 原理CAS 全称 Compare And Swap（比较与交换），是一种无锁算法。在不使用锁（没有线程被阻塞）的情况下实现多线程之间的变量同步。java.util.concurrent 包中的原子类就是通过 CAS 来实现了乐观锁。CAS 算法涉及到三个操作数：需要读写的内存值 V。进行比较的值 A。要写入的新值 B。当且仅当 V 的值等于 A 时，CAS 通过原子方式用新值 B 来更新 V 的值（“比较 + 更新”整体是一个原子操作），否则不会执行任何操作。一般情况下，“更新”是一个不断重试的操作。之前提到 java.util.concurrent 包中的原子类，就是通过 CAS 来实现了乐观锁，那么我们进入原子类 AtomicInteger 的源码，看一下 AtomicInteger 的定义：根据定义我们可以看出各属性的作用：unsafe：获取并操作内存的数据。valueOffset：存储 value 在 AtomicInteger 中的偏移量。value：存储 AtomicInteger 的 int 值，该属性需要借助 volatile 关键字保证其在线程间是可见的。接下来，我们查看 AtomicInteger 的自增函数 incrementAndGet() 的源码时，发现自增函数底层调用的是 unsafe.getAndAddInt()。但是由于 JDK 本身只有 Unsafe.class，只通过 class 文件中的参数名，并不能很好的了解方法的作用，所以我们通过 OpenJDK 8 来查看 Unsafe 的源码：// ------------------------- JDK 8 ------------------------- // AtomicInteger 自增方法 public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1.html) + 1; } // Unsafe.class public final int getAndAddInt(Object var1, long var2, int var4.html) { int var5; do { var5 = this.getIntVolatile(var1, var2.html); } while (!this.compareAndSwapInt(var1, var2, var5, var5 + var4.html)); return var5; } // ------------------------- OpenJDK 8 ------------------------- // Unsafe.java public final int getAndAddInt(Object o, long offset, int delta.html) { int v; do { v = getIntVolatile(o, offset.html); } while (!compareAndSwapInt(o, offset, v, v + delta.html)); return v; }根据 OpenJDK 8 的源码我们可以看出，getAndAddInt() 循环获取给定对象 o 中的偏移量处的值 v，然后判断内存值是否等于 v。如果相等则将内存值设置为 v + delta，否则返回 false，继续循环进行重试，直到设置成功才能退出循环，并且将旧值返回。整个“比较 + 更新”操作封装在 compareAndSwapInt() 中，在 JNI 里是借助于一个 CPU 指令完成的，属于原子操作，可以保证多个线程都能够看到同一个变量的修改值。后续 JDK 通过 CPU 的 cmpxchg 指令，去比较寄存器中的 A 和内存中的值 V。如果相等，就把要写入的新值 B 存入内存中。如果不相等，就将内存值 V 赋值给寄存器中的值 A。然后通过 Java 代码中的 while 循环再次调用 cmpxchg 指令进行重试，直到设置成功为止。CAS 的三大问题CAS 虽然很高效，但是它也存在三大问题，这里也简单说一下：ABA 问题。CAS 需要在操作值的时候检查内存值是否发生变化，没有发生变化才会更新内存值。但是如果内存值原来是 A，后来变成了 B，然后又变成了 A，那么 CAS 进行检查时会发现值没有发生变化，但是实际上是有变化的。解决思路：在变量前面添加版本号，每次变量更新的时候都把版本号加一，这样变化过程就从"A－B－A"变成了"1A－2B－3A"。JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题，具体操作封装在 compareAndSet() 中。compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等，如果都相等，则以原子方式将引用值和标志的值设置为给定的更新值。循环时间长开销大。CAS 操作如果长时间不成功，会导致其一直自旋，给 CPU 带来非常大的开销。只能保证一个共享变量的原子操作。对一个共享变量执行操作时，CAS 能够保证原子操作，但是对多个共享变量操作时，CAS 是无法保证操作的原子性的。Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性，可以把多个变量放在一个对象里来进行 CAS 操作。2. 自旋锁 VS 适应性自旋锁在介绍自旋锁前，我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成，这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单，状态转换消耗的时间有可能比用户代码执行的时间还要长。在许多场景中，同步资源的锁定时间很短，为了这一小段时间去切换线程，线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器，能够让两个或以上的线程同时并行执行，我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间，看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”，我们需让当前线程进行自旋。如果在自旋完成后前面锁定同步资源的线程已经释放了锁，那么当前线程就可以不必阻塞而是直接获取同步资源，从而避免切换线程的开销。这就是自旋锁。自旋锁本身是有缺点的，它不能代替阻塞。自旋等待虽然避免了线程切换的开销，但它要占用处理器时间。如果锁被占用的时间很短，自旋等待的效果就会非常好。反之，如果锁被占用的时间很长，那么自旋的线程只会白浪费处理器资源。所以，自旋等待的时间必须要有一定的限度，如果自旋超过了限定次数（默认是 10 次，可以使用 -XX:PreBlockSpin 来更改）没有成功获得锁，就应当挂起线程。自旋锁的实现原理同样也是 CAS，AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作，如果修改数值失败则通过循环来执行自旋，直至修改成功。自旋锁在 JDK 1.4.2 中引入，使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启，并且引入了自适应的自旋锁（适应性自旋锁）。自适应意味着自旋的时间（次数）不再固定，而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上，自旋等待刚刚成功获得过锁，并且持有锁的线程正在运行中，那么虚拟机就会认为这次自旋也是很有可能再次成功，进而它将允许自旋等待持续相对更长的时间。如果对于某个锁，自旋很少成功获得过，那在以后尝试获取这个锁时将可能省略掉自旋过程，直接阻塞线程，避免浪费处理器资源。在自旋锁中另有三种常见的锁形式：TicketLock、CLHLock 和 MCSLock，本文中仅做名词介绍，不做深入讲解，感兴趣的同学可以自行查阅相关资料。3. 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁这四种锁是指锁的状态，专门针对 synchronized 的。在介绍这四种锁状态之前还需要介绍一些额外的知识。首先为什么 Synchronized 能实现线程同步？在回答这个问题之前我们需要了解两个重要的概念："Java 对象头”、"Monitor"。Java 对象头synchronized 是悲观锁，在操作同步资源之前需要给同步资源先加锁，这把锁就是存在 Java 对象头里的，而 Java 对象头又是什么呢？我们以 Hotspot 虚拟机为例，Hotspot 的对象头主要包括两部分数据：Mark Word（标记字段）、Klass Pointer（类型指针）。Mark Word：默认存储对象的 HashCode，分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据，所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间，也就是说在运行期间 Mark Word 里存储的数据会随着锁标志位的变化而变化。Klass Pointer：对象指向它的类元数据的指针，虚拟机通过这个指针来确定这个对象是哪个类的实例。MonitorMonitor 可以理解为一个同步工具或一种同步机制，通常被描述为一个对象。每一个 Java 对象就有一把看不见的锁，称为内部锁或者 Monitor 锁。Monitor 是线程私有的数据结构，每一个线程都有一个可用 monitor record 列表，同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联，同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识，表示该锁被这个线程占用。现在话题回到 synchronized，synchronized 通过 Monitor 来实现线程同步，Monitor 是依赖于底层的操作系统的 Mutex Lock（互斥锁）来实现的线程同步。如同我们在自旋锁中提到的“阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成，这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单，状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是 synchronized 最初实现同步的方式，这就是 JDK 6 之前 synchronized 效率低的原因。这种依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”，JDK 6 中为了减少获得锁和释放锁带来的性能消耗，引入了“偏向锁”和“轻量级锁”。所以目前锁一共有 4 种状态，级别从低到高依次是：无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。通过上面的介绍，我们对 synchronized 的加锁机制以及相关知识有了一个了解，那么下面我们给出四种锁状态对应的 Mark Word 内容，然后再分别讲解四种锁状态的思路以及特点：锁状态存储内容锁标志位无锁对象的 hashCode、对象分代年龄、是否是偏向锁（0）01偏向锁偏向线程 ID、偏向时间戳、对象分代年龄、是否是偏向锁（1）01轻量级锁指向栈中锁记录的指针00重量级锁指向互斥量（重量级锁）的指针10无锁无锁没有对资源进行锁定，所有的线程都能访问并修改同一个资源，但同时只有一个线程能修改成功。无锁的特点就是修改操作在循环内进行，线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出，否则就会继续循环尝试。如果有多个线程修改同一个值，必定会有一个线程能修改成功，而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的 CAS 原理及应用即是无锁的实现。无锁无法全面代替有锁，但无锁在某些场合下的性能是非常高的。偏向锁偏向锁是指一段同步代码一直被一个线程所访问，那么该线程会自动获取锁，降低获取锁的代价。在大多数情况下，锁总是由同一线程多次获得，不存在多线程竞争，所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。当一个线程访问同步代码块并获取锁时，会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁，而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径，因为轻量级锁的获取及释放依赖多次 CAS 原子指令，而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。偏向锁只有遇到其他线程尝试竞争偏向锁时，持有偏向锁的线程才会释放锁，线程不会主动释放偏向锁。偏向锁的撤销，需要等待全局安全点（在这个时间点上没有字节码正在执行），它会首先暂停拥有偏向锁的线程，判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁（标志位为"01"）或轻量级锁（标志位为"00"）的状态。偏向锁在 JDK 6 及以后的 JVM 里是默认启用的。可以通过 JVM 参数关闭偏向锁：-XX:-UseBiasedLocking，关闭之后程序默认会进入轻量级锁状态。轻量级锁是指当锁是偏向锁的时候，被另外的线程所访问，偏向锁就会升级为轻量级锁，其他线程会通过自旋的形式尝试获取锁，不会阻塞，从而提高性能。在代码进入同步块的时候，如果同步对象锁状态为无锁状态（锁标志位为"01"状态，是否为偏向锁为"0"），虚拟机首先将在当前线程的栈帧中建立一个名为锁记录（Lock Record）的空间，用于存储锁对象目前的 Mark Word 的拷贝，然后拷贝对象头中的 Mark Word 复制到锁记录中。拷贝成功后，虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针，并将 Lock Record 里的 owner 指针指向对象的 Mark Word。如果这个更新动作成功了，那么这个线程就拥有了该对象的锁，并且对象 Mark Word 的锁标志位设置为"00"，表示此对象处于轻量级锁定状态。如果轻量级锁的更新操作失败了，虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧，如果是就说明当前线程已经拥有了这个对象的锁，那就可以直接进入同步块继续执行，否则说明多个线程竞争锁。若当前只有一个等待线程，则该线程通过自旋进行等待。但是当自旋超过一定的次数，或者一个线程在持有锁，一个在自旋，又有第三个来访时，轻量级锁升级为重量级锁。重量级锁升级为重量级锁时，锁标志的状态值变为"10"，此时 Mark Word 中存储的是指向重量级锁的指针，此时等待锁的线程都会进入阻塞状态。整体的锁状态升级流程如下：综上，偏向锁通过对比 Mark Word 解决加锁问题，避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题，避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。4. 公平锁 VS 非公平锁公平锁是指多个线程按照申请锁的顺序来获取锁，线程直接进入队列中排队，队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低，等待队列中除第一个线程以外的所有线程都会阻塞，CPU 唤醒阻塞线程的开销比非公平锁大。非公平锁是多个线程加锁时直接尝试获取锁，获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用，那么这个线程可以无需阻塞直接获取到锁，所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销，整体的吞吐效率高，因为线程有几率不阻塞直接获得锁，CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死，或者等很久才会获得锁。直接用语言描述可能有点抽象，这里作者用从别处看到的一个例子来讲述一下公平锁和非公平锁。如上图所示，假设有一口水井，有管理员看守，管理员有一把锁，只有拿到锁的人才能够打水，打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水，如果前面有人正在打水，那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人，如果是的话，才会给你锁让你去打水；如果你不是排第一的人，就必须去队尾排队，这就是公平锁。但是对于非公平锁，管理员对打水的人没有要求。即使等待队伍里有排队等待的人，但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时，刚好来了一个插队的人，这个插队的人是可以直接从管理员那里拿到锁去打水，不需要排队，原本排队等待的人只能继续等待。如下图所示：接下来我们通过 ReentrantLock 的源码来讲解公平锁和非公平锁。根据代码可知，ReentrantLock 里面有一个内部类 Sync，Sync 继承 AQS（AbstractQueuedSynchronizer），添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。它有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。ReentrantLock 默认使用非公平锁，也可以通过构造器来显式地指定使用公平锁。下面我们来看一下公平锁与非公平锁的加锁方法的源码：通过上图中的源代码对比，我们可以明显的看出公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件：hasQueuedPredecessors()。再进入 hasQueuedPredecessors()，可以看到该方法主要做一件事情：主要是判断当前线程是否位于同步队列中的第一个。如果是则返回 true，否则返回 false。综上，公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁，从而实现公平的特性。非公平锁加锁时不考虑排队等待问题，直接尝试获取锁，所以存在后申请却先获得锁的情况。5. 可重入锁 VS 非可重入锁可重入锁又名递归锁，是指在同一个线程在外层方法获取锁的时候，再进入该线程的内层方法会自动获取锁（前提锁对象得是同一个对象或者 class），不会因为之前已经获取过还没释放而阻塞。Java 中 ReentrantLock 和 synchronized 都是可重入锁，可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析：public class Widget { public synchronized void doSomething() { System.out.println("方法 1 执行...".html); doOthers(); } public synchronized void doOthers() { System.out.println("方法 2 执行...".html); } }在上面的代码中，类中的两个方法都是被内置锁 synchronized 修饰的，doSomething() 方法中调用 doOthers() 方法。因为内置锁是可重入的，所以同一个线程在调用 doOthers() 时可以直接获得当前对象的锁，进入 doOthers() 进行操作。如果是一个不可重入锁，那么当前线程在调用 doOthers() 之前需要将执行 doSomething() 时获取当前对象的锁释放掉，实际上该对象锁已被当前线程所持有，且无法释放。所以此时会出现死锁。而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢？我们通过图示和源码来分别解析一下。还是打水的例子，有多个人在排队打水，此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时，第一个水桶和锁绑定并打完水之后，第二个水桶也可以直接和锁绑定并开始打水，所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行，后续等待的人也能够打到水。这就是可重入锁。但如果是非可重入锁的话，此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁，导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁，整个等待队列中的所有线程都无法被唤醒。之前我们说过 ReentrantLock 和 synchronized 都是重入锁，那么我们通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。首先 ReentrantLock 和 NonReentrantLock 都继承父类 AQS，其父类 AQS 中维护了一个同步状态 status 来计数重入次数，status 初始值为 0。当线程尝试获取锁时，可重入锁先尝试获取并更新 status 值，如果 status == 0 表示没有其他线程在执行同步代码，则把 status 置为 1，当前线程开始执行。如果 status != 0，则判断当前线程是否是获取到这个锁的线程，如果是的话执行 status+1，且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前 status 的值，如果 status != 0 的话会导致其获取锁失败，当前线程阻塞。释放锁时，可重入锁同样先获取当前 status 的值，在当前线程是持有锁的线程的前提下。如果 status-1 == 0，则表示当前线程所有重复获取锁的操作都已经执行完毕，然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后，直接将 status 置为 0，将锁释放。6. 独享锁 VS 共享锁独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念，然后通过 ReentrantLock 和 ReentrantReadWriteLock 的源码来介绍独享锁和共享锁。独享锁也叫排他锁，是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后，则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后，则其他线程只能对 A 再加共享锁，不能加排它锁。获得共享锁的线程只能读数据，不能修改数据。独享锁与共享锁也是通过 AQS 来实现的，通过实现不同的方法，来实现独享或者共享。下图为 ReentrantReadWriteLock 的部分源码：我们看到 ReentrantReadWriteLock 有两把锁：ReadLock 和 WriteLock，由词知意，一个读锁一个写锁，合称“读写锁”。再进一步观察可以发现 ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是 AQS 的一个子类，这种结构在 CountDownLatch、ReentrantLock、Semaphore 里面也都存在。在 ReentrantReadWriteLock 里面，读锁和写锁的锁主体都是 Sync，但读锁和写锁的加锁方式不一样。读锁是共享锁，写锁是独享锁。读锁的共享锁可保证并发读非常高效，而读写、写读、写写的过程互斥，因为读锁和写锁是分离的。所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大提升。那读锁和写锁的具体加锁方式有什么区别呢？在了解源码之前我们需要回顾一下其他知识。在最开始提及 AQS 的时候我们也提到了 state 字段（int 类型，32 位），该字段用来描述有多少线程获持有锁。在独享锁中这个值通常是 0 或者 1（如果是重入锁的话 state 值就是重入的次数），在共享锁中 state 就是持有锁的数量。但是在 ReentrantReadWriteLock 中有读、写两把锁，所以需要在一个整型变量 state 上分别描述读锁和写锁的数量（或者也可以叫状态）。于是将 state 变量“按位切割”切分成了两个部分，高 16 位表示读锁状态（读锁个数），低 16 位表示写锁状态（写锁个数）。如下图所示：了解了概念之后我们再来看代码，先看写锁的加锁源码：protected final boolean tryAcquire(int acquires.html) { Thread current = Thread.currentThread(); int c = getState(); // 取到当前锁的个数 int w = exclusiveCount(c.html); // 取写锁的个数 w if (c != 0.html) { // 如果已经有线程持有了锁 (c!=0.html) // (Note: if c != 0 and w == 0 then shared count != 0.html) if (w == 0 || current != getExclusiveOwnerThread(.html)) // 如果写线程数（w）为 0（换言之存在读锁）或者持有锁的线程不是当前线程就返回失败 return false; if (w + exclusiveCount(acquires.html) > MAX_COUNT) // 如果写入锁的数量大于最大数（65535，2 的 16 次方 -1）就抛出一个 Error。 throw new Error("Maximum lock count exceeded".html); // Reentrant acquire setState(c + acquires.html); return true; } if (writerShouldBlock(.html) || !compareAndSetState(c, c + acquires.html)) // 如果当且写线程数为 0，并且当前线程需要阻塞那么就返回失败；或者如果通过 CAS 增加写线程数失败也返回失败。 return false; setExclusiveOwnerThread(current.html); // 如果 c=0,w=0 或者 c>0,w>0（重入），则设置当前线程或锁的拥有者 return true; }这段代码首先取到当前锁的个数 c，然后再通过 c 来获取写锁的个数 w。因为写锁是低 16 位，所以取低 16 位的最大值与当前的 c 做与运算（int w = exclusiveCount(c.html);），高 16 位和 0 与运算后是 0，剩下的就是低位运算的值，同时也是持有写锁的线程数目。在取到写锁线程的数目后，首先判断是否已经有线程持有了锁。如果已经有线程持有了锁 (c!=0.html)，则查看当前写锁线程的数目，如果写线程数为 0（即此时存在读锁）或者持有锁的线程不是当前线程就返回失败（涉及到公平锁和非公平锁的实现）。如果写入锁的数量大于最大数（65535，2 的 16 次方 -1）就抛出一个 Error。如果当且写线程数为 0（那么读线程也应该为 0，因为上面已经处理 c!=0 的情况），并且当前线程需要阻塞那么就返回失败；如果通过 CAS 增加写线程数失败也返回失败。如果 c=0, w=0 或者 c>0, w>0（重入），则设置当前线程或锁的拥有者，返回成功！tryAcquire() 除了重入条件（当前线程为获取了写锁的线程）之外，增加了一个读锁是否存在的判断。如果存在读锁，则写锁不能被获取，原因在于：必须确保写锁的操作对读锁可见，如果允许读锁在已被获取的情况下对写锁的获取，那么正在运行的其他读线程就无法感知到当前写线程的操作。因此，只有等待其他读线程都释放了读锁，写锁才能被当前线程获取，而写锁一旦被获取，则其他读写线程的后续访问均被阻塞。写锁的释放与 ReentrantLock 的释放过程基本类似，每次释放均减少写状态，当写状态为 0 时表示写锁已被释放，然后等待的读写线程才能够继续访问读写锁，同时前次写线程的修改对后续的读写线程可见。接着是读锁的代码：protected final int tryAcquireShared(int unused.html) { Thread current = Thread.currentThread(); int c = getState(); if (exclusiveCount(c.html) != 0 && getExclusiveOwnerThread() != current) return -1; // 如果其他线程已经获取了写锁，则当前线程获取读锁失败，进入等待状态 int r = sharedCount(c.html); if (!readerShouldBlock(.html) && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT.html)) { if (r == 0.html) { firstReader = current; firstReaderHoldCount = 1; } else if (firstReader == current.html) { firstReaderHoldCount++; } else { HoldCounter rh = cachedHoldCounter; if (rh == null || rh.tid != getThreadId(current.html)) cachedHoldCounter = rh = readHolds.get(); else if (rh.count == 0.html) readHolds.set(rh.html); rh.count++; } return 1; } return fullTryAcquireShared(current.html); }可以看到在 tryAcquireShared(int unused.html) 方法中，如果其他线程已经获取了写锁，则当前线程获取读锁失败，进入等待状态。如果当前线程获取了写锁或者写锁未被获取，则当前线程（线程安全，依靠 CAS 保证）增加读状态，成功获取读锁。读锁的每次释放（线程安全的，可能有多个读线程同时释放读锁）均减少读状态，减少的值是"1<<16"。所以读写锁才能实现读读的过程共享，而读写、写读、写写的过程互斥。此时，我们再回头看一下互斥锁 ReentrantLock 中公平锁和非公平锁的加锁源码：我们发现在 ReentrantLock 虽然有公平锁和非公平锁两种，但是它们添加的都是独享锁。根据源码所示，当某一个线程调用 lock 方法获取锁时，如果同步资源没有被其他线程锁住，那么当前线程在使用 CAS 更新 state 成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用，那么就会加锁失败。所以可以确定 ReentrantLock 无论读操作还是写操作，添加的锁都是独享锁。结语本文对 Java 中常用的锁以及常见的锁的概念进行了基本介绍，并从源码以及实际应用的角度进行了对比分析。限于篇幅以及个人水平，没有在本篇文章中对所有内容进行深层次的讲解。其实 Java 本身已经对锁本身进行了良好的封装，降低了研发同学在平时工作中的使用难度。但是研发同学也需要熟悉锁的底层原理，不同场景下选择最适合的锁。而且源码中的思路都是非常好的思路，也是值得大家去学习和借鉴的。参考资料《Java 并发编程艺术》Java 中的锁Java CAS 原理剖析Java 并发——关键字 synchronized 解析Java synchronized 原理总结聊聊并发（二）——Java SE1.6 中的 Synchronized深入理解读写锁—ReadWriteLock 源码分析【JUC】JDK1.8 源码分析之 ReentrantReadWriteLockJava 多线程（十）之 ReentrantReadWriteLock 深入分析Java–读写锁的实现原理说明本文源码分析基于 JDK 8。关于偏向锁：JDK 15 中偏向锁默认禁用（-XX:-UseBiasedLocking），JDK 16 中已移除偏向锁相关代码。若使用更高版本 JDK，锁升级流程可能直接从无锁到轻量级锁。
一、两种方案分库分表一般业界，对订单数据的分库分表，笔者了解，有两类思路：按照订单号来切分、按照用户id来切分。方案一、按照订单号来做hash分散订单数据 把订单号看作是一个字符串，做hash，分散到多个服务器去。 具体到哪个库、哪个表存储数据呢？订单号里面的数字来记录着。 现在的微信红包。它的订单分库分表，是对订单号进行hash计算。不是什么取模、取整数。这样数据是均匀分散的。 然后订单号的末尾3个数，里面是包含了库名称、表名称的。 如果要查询某用户的所有订单呢？ 由于是根据订单号来分散数据的。他的订单分散在了多个库、多个表中。 总不能去所有的库，所有的表扫描吧。这样效率很低。 其实按照uid切分订单，一样会遇到查询的问题。比如要查询a订单的信息。分库分表的规则是按照uid，都不知道数据在哪个库，无从查。 后续说明解决思路。 一般使用方案二的比较多，一个用户的所有订单，都在一张表里面，那么做分页展示的时候，就容易。 方案二、按照用户id打散订单数据。以uid来切分数据，有两种思路：一种是，某个范围的uid订单到哪些库。0到2千万uid，对应的订单数据到a库、a表。2千万到4千万对应的订单到b库。为什么这种方案用得比较少呢？容易出现瓶颈吗。某个范围内的用户，下单量比较多，那么造成这个库的压力特别大。其他库却没什么压力。第二种是，使用uid取模运算。第二种方案业界用得比较多。一方面、处理简单，程序上做取模运算就好了。另一方面、使用取模的方式，数据比较均匀分散到多个库去了。不容易出现单个库性能瓶颈。但是不好处也有：即要扩容的时候，比较麻烦。就需要迁移数据了。要扩容的时候，为了减少迁移的数据量，一般扩容是以倍数的形式增加。比如原来是8个库，扩容的时候，就要增加到16个库，再次扩容，就增加到32个库。这样迁移的数据量，就小很多了。这个问题不算很大问题，毕竟一次扩容，可以保证比较长的时间，而且使用倍数增加的方式，已经减少了数据迁移量。下面笔者，分析一下按照用户id取模的方式分库分表。按照用户id作为key来切分订单数据，具体如下：1、 库名称定位：用户id末尾4位 Mod 32。Mod表示除以一个数后，取余下的数。比如除以32后，余下8，余数就是8。代码符号是用%表示：15%4=3。2、表名称定位：（用户id末尾4位 Dev 32） Mod 32。Dev表示除以一个数，取结果的整数。比如得到结果是25.6，取整就是25。代码用/来表示：$get_int = floor(15/4.html)。15除以4，是一个小数3.75，向下取整就是3。一定是向下取整，向上取整就变成了4了。按照上面的规则：总共可以表示多少张表呢？32个库*每个库32个表=1024张表。如果想表的数量小，就把32改小一些。上面是用计算机术语来表示， 下面用通俗的话描述。1、库名称计算用户id的后4位数，取32的模(取模就是除以这个数后，余多少.html)。余下的数，是0-31之间。这样可以表示从0-31之间，总共32个数字。用这个32个数字代表着32个库名称：order\_db\_0、order\_db\_2.........................order\_db\_312、表名称计算最后要存储定到哪个表名里面去呢？用户id的最后4位数，除以32，取整数。将整数除以32，得到余数，能够表示从0-31之间32个数字，表示表名称。表名称类似这样：order\_tb\_1、order\_tb\_2..........................order\_tb\_31。一个库里面，总共32个表名称。比如用户id：19408064，用最后4位数字8064除以32，得到是251.9，取它的整数是251。接着将251除以32，取余数，余数为27。为了保持性能，每张表的数据量要控制。单表可以维持在一千万-5千万行的数据。1024*一千万。哇，可以表示很多数据了。三、思考优点和缺点优点订单水平分库分表，为什么要按照用户id来切分呢？好处：查询指定用户的所有订单，避免了跨库跨表查询。因为，根据一个用户的id来计算节点，用户的id是规定不变的，那么计算出的值永远是固定的(x库的x表.html)那么保存订单的时候，a用户的所有订单，都是在x库x表里面。需要查询a用户所有订单时，就不用进行跨库、跨表去查询了。缺点这种方式也不是没有缺点。缺点在于：数据分散不均匀，某些表的数据量特别大，某些表的数据量很小。因为某些用户下单量多，打个比方，1000-2000这个范围内的用户，下单特别多，而他们的id根据计算规则，都是分到了x库x表。造成这个表的数据量大，单表的数据量撑到极限后，咋办呢?总结一下：每种分库分表方案也不是十全十美，都是有利有弊的。目前来说，这种使用用户id来切分订单数据的方案，还是被大部分公司给使用。实际效果还不错。程序员省事，至于数据量暴涨，以后再说呢。毕竟公司业务发展到什么程度，不知道的，项目存活期多久，未来不确定。先扛住再说。比较好的方案是不是：又能均匀分散、又能避免单表数据量暴涨方便扩容。以前看过一篇文章介绍过使用节点来存储分库分表。笔者暂时没完整的思路。二、查询需求的考虑方案一的查询问题方案一的情况下，由于是按照订单号做分散数据到多个库、多个表。如果需要查询a用户的所有订单，咋办？需要跨库、跨表查询。这样效率低。不可行。方案二的查询问题如果是按照uid来切分订单数据，在实际应用中一些很频繁的查询需求像下面这样：1、后台、前台，往往是输入一个订单号，查询这个订单的数据。select操作2、然后修改这个订单的相关状态。update操作。由于是，按照用户编号将订单数据分散在各个库、各个表中。那输入订单号，怎么知道去哪个库、哪个表查询呢？不可能所有的库、所有表都查询一遍，效率太低，不可行。三、解决办法：建立用户id和订单号的索引关系表 无论是根据用户id来切分订单，还是根据订单号切分数据。总不能十全十美的。 写到这里，发现真的没有一种技术方案是十全十美的，看，使用用户id来切分订单，好处是有了，坏处也出来了。 不过没关系，早要有心里承受：不要觉得技术是完美无缺的。针对这种情况，想办法去解决办法。 思路：既然是根据订单号分散订单数据，如果需要知道某个用户所有的订单。只要我能知道了a用户的所有的订单号，那么就可以根据订单号定位到表名称了。 思路：既然是根据用户id来分散订单数据的。那么只要知道了这个订单号是谁的(得到了用户id.html)，就能知道去哪个库、哪个表查询数据了。 那怎么知道是谁的呢？建立一个索引关系表，暂且叫做订单用户关系索引表order\_user\_idx。咱们命名为了保持维护性，还是一看能够知道是干嘛用的。 存储的数据包括两项：订单号、用户编号。 这样输入订单号，可以去查询索引关系表，获取到用户编号。 得到了用户编号，问题解决了。订单信息是根据用户编号分库分表的，可以直接定位到x库x表了。 当创建订单的时候，就要把关系插入到表里面去了。保存关系记录时，为了减低用户等待时间，不需要实时，做成异步。加入到消息队列中去操作。 **订单用户索引关系表的性能优化** 考虑到，一个用户的下的订单可能是几十个，也可能是几百个，随着时间的推移，会越来越多。这个索引关系表，也不能使用单表存储。 所以对这个订单用户关系索引表，也要进行分库分表：直接根据订单号取模进行分库分表。是不是感觉挺麻烦了。确实麻烦。不过能解决问题就好。暂时没想到其他办法了。 一个订单，在创建的时候，就已经分配好给指定用户了。只是一个关系对应，以后也不会变化。 根据这个特点。订单用户索引关系表，其实可以放到内存中缓存起来应对查询需求(数据库那张索引关系表也要有，数据要持久化.html)。 平时查询的时候，走内存缓存查询。如果查询不到，再走数据库查询一下关系。这样速度就很快了。 结语：水平分表，其实折腾起来工作量挺大的，切分了后，出现新的问题，代码查询又得改，要提供其他解决办法。所以经常看到别人说，能不水平分表，尽量不要分，业务没达到瓶颈，先用硬件扛住，后面再考虑水平切分数据。看银行、联通这些有钱的企业，使用性能强劲的oracle搭配小型机服务器，单表的数据量达到十多亿。小型机是专门定制的，几十万一台。性能很强。分库分表是很耗费时间、当你交易量做到上亿规模的时候，那时，公司的实力应该可以了，经济方面有足够的实力聘请经验丰富的技术来重构。 思考一、b2b平台的订单分卖家和买家的时候，选择什么字段来分库分表呢？上面讨论的情况是，b2c平台。订单的卖家就一个，就是平台自己。b2b平台，上面支持开店，买家和卖家都要能够登陆看到自己的订单。先来看看，分表使用买家id分库分表和根据卖家id分库分表，两种办法出现的问题如果按买家id来分库分表。有卖家的商品，会有n个用户购买，他所有的订单，会分散到多个库多个表中去了，卖家查询自己的所有订单，跨库、跨表扫描，性能低下。如果按卖家id分库分表。买家会在n个店铺下单。订单就会分散在多个库、多个表中。买家查询自己所有订单，同样要去所有的库、所有的表搜索，性能低下。所以，无论是按照买家id切分订单表，还是按照卖家id切分订单表。两边都不讨好。淘宝的做法是拆分买家库和卖家库，也就是两个库：买家库、卖家库。买家库，按照用户的id来分库分表。卖家库，按照卖家的id来分库分表。实际上是通过数据冗余解决的：一个订单，在买家库里面有，在卖家库里面也存储了一份。下订单的时候，要写两份数据。先把订单写入买家库里面去，然后通过消息中间件来同步订单数据到卖家库里面去。买家库的订单a修改了后，要发异步消息，通知到卖家库去，更改状态。思考二：那可以按订单号来分库分表吗?这样分库分表的话，用户有10个订单，订单不见得都在一个库、一个表里面。查询a用户的所有订单，就会变得麻烦了。尤其是要进行分页展示，分散在不同的表，甚至不同的数据库服务器，也比较耗费性能。那么订单号里面，最好是要有分库分表信息。淘宝的是在订单号里面添加了卖家id末2位、买家id末2位。这样的好处是干嘛呢？直接定位到具体的库、具体的表去了？怎么根据这个呢。因为分库、分表的规则，买家库是按照卖家id末尾2位数分，卖家库是按照卖家id末尾两位分。所以，只要从订单号里面拿到了这些数字信息，就知道在哪个库，哪个表了。这种办法，与微信的红包订单号是类似的，末尾三位数包含了库信息、表信息。按照这样，其实就没必要使用订单号来计算了？如果是按照用户id的后4位数取模分散订单数据。那么订单号的生成，可以在后面加上用户id的后4位数。那么，虽然是按照用户id来对订单表分库分表的。其实可以直接根据订单号，知道这个订单在哪个库哪个表了。如果是b2b系统，涉及到卖家和买家。那么可以把卖家和买家的id后面4位都加进去。不过是不是订单号太长了？思考三、按照订单的时间来分表如何?一月一张表。一年一张表。用户的所有订单，会分散在不同的库、不同的表中。按照时间分，在切分订单数据的时候，业界用得比较少。出现如下两个问题：1、如果需要分页查询某个用户的所有订单数据，就会出现跨库、跨表查询。效率低。可以做折中：限制只能查一个范围内的订单，比如一次只能查询，一年以内或者一个月以内的订单。 2、某个时间集中写入数据，出现瓶颈。如一个月一张表。这个月的订单量暴涨呢。那么写入新的订单数据都会操作这张表。造成性能低下。影响整个业务系统交易。真正好的分表方案，尽量将写数据分散到多个表去，达到分流效果，系统的并发能力就提高了。