跳至主要內容

Java synchronized关键字详解

fangzhipeng约 4154 字大约 14 分钟

synchronized关键字的作用

synchronized 是 Java 中用于实现线程互斥和同步的关键字。它可以应用于方法或代码块,用于保护共享资源,以避免并发访问导致的数据不一致性。

具体而言,synchronized 的作用有以下几个方面:

  1. 互斥性:使用 synchronized 关键字可以确保在同一时间只有一个线程可以执行被 synchronized 修饰的方法或代码块,从而避免多个线程同时访问临界资源导致的数据竞争。保证了原子性。

  2. 可见性:synchronized 不仅提供了互斥性,还提供了可见性。当一个线程进入 synchronized 块时,它将会获取锁,并且在释放锁之前,它会将对共享变量的修改刷新到主内存中,从而保证其他线程读取到最新的值。

  3. 内存屏障效果:synchronized 还会在进入和退出 synchronized 块时,自动插入内存屏障(Memory Barrier),这个内存屏障具有特殊的作用,可以禁止在 synchronized 块内部和外部的指令重排序,保证了代码的顺序性。保证了有序性。

synchronized 关键字的作用是确保多线程环境下共享资源的正确访问和数据的一致性。它解决了线程安全问题,并提供了互斥性、可见性和顺序性保证。

使用示例

当使用 synchronized 关键字时,可以通过以下两种方式来实现线程同步:

  1. 同步方法(Synchronized Methods):
    在方法声明时使用 synchronized 关键字,这将使整个方法成为一个临界区,只有一个线程可以进入执行方法,其他线程需要等待该线程执行完毕后才能继续执行。

    public synchronized void methodName() {
        // 这里是需要线程同步的代码块
    }
    
  2. 同步代码块(Synchronized Blocks):
    在代码块内部使用 synchronized 关键字,只对特定的代码块进行同步,而非整个方法。这样可以减少同步的范围,提高多线程程序的性能。

    public void methodName() {
        // 非同步的代码块
    
        synchronized (object) {
            // 这里是需要线程同步的代码块
        }
    
        // 非同步的代码块
    }
    

无论是同步方法还是同步代码块,都需要一个共享的对象作为锁。这个对象可以是任何 Java 对象,如类的实例变量、类的静态变量或者常量。只有当线程获取到锁时,才能执行同步代码块内部的操作。其他线程需要等待锁的释放。

以下是一个示例,演示了如何使用 synchronized 来实现线程同步:

public class SynchronizedExample {
    private int count = 0;
    private Object lock = new Object();

    public synchronized void increment() {
        count++;
    }

    public void doWork() {
        // 非同步的代码块

        synchronized (lock) {
            // 需要线程同步的代码块
            increment();
        }

        // 非同步的代码块
    }
}

在上面的示例中,通过 synchronized 关键字保证了 increment() 方法的原子性操作,以及同步代码块内的操作。这样就能确保多个线程对 count 变量的访问是安全的,不会出现数据不一致的情况。

Synchronized原理

临界区是指一个需要互斥执行的代码段,只允许一个线程进入临界区执行。为了确保线程安全,需要使用锁机制来保护临界区。

当线程想要进入临界区时,首先会尝试获取锁(通过调用lock()方法)。如果当前没有其他线程持有锁,则该线程成功获取锁并进入临界区,此时称为该线程持有锁。如果锁已经被其他线程持有,则该线程将进入等待状态,直到持有锁的线程解锁。

synchronized关键字并没有显示的为临界区执行加锁和解锁的步骤。

Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样有助于避免忘记解锁而导致的问题。这种自动处理的方式简化了代码编写,同时减少了潜在的错误。

我们先来了解Synchronized的原理,我们先通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的。

package io.github.forezp.concurrentlab.synchro;

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

先执行javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class,我们就可以看到上面类的字节码信息:

image-20231210110738038
image-20231210110738038

其中我们需要关注monitorenter和monitorexit的指令含义,直接参考JVM规范中描述:

monitorenter :

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

再来看一下同步方法:

 public class SynchronizedMethod {
     public synchronized void method() {
        System.out.println("Hello World!");   ]
        }
 }

反编译查看字节码如下:

img
img

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

synchronized优化

Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。

当线程不能获取锁的时候,就会阻塞,这时会有线程切换的开销,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。

因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。

对象头

对象头的内容非常多这里我们只做简单介绍以引出后文。在 JVM 中对象布局分为三块区域:

  • 对象头
  • 实例数据
  • 对齐填充
img
img

当线程访问同步块时首先需要获得锁并把相关信息存储在对象头中。对象头的Mark Word:存储自身的运行时数据,例如 HashCode、GC 年龄、锁相关信息等内容,如下:

从JDK6开始,对synchronized的实现机制进行了较大调整,包括引入了CAS自旋、自适应的CAS自旋、锁消除、锁粗化、偏向锁和轻量级锁等一系列优化策略。

总体上来说锁状态升级流程如下:

img
img

偏向锁

偏向锁是一种用于减少同步操作的开销的优化技术。它的设计思想是假定大多数情况下,锁总是由同一线程多次获得,因此在该线程第一次获得锁后,JVM会将这个锁标记为偏向锁。

之后,当该线程再次请求这个锁时,无需进行同步操作,因为JVM已经假定该线程会继续拥有锁。

只有当其他线程请求锁时,偏向锁才会撤销,改为轻量级锁等其他形式的锁。

通过偏向锁,JVM避免了每次加锁和解锁时都需要竞争的情况,从而减少了不必要的开销,提高了程序的性能。这项优化特别适用于线程独占锁的场景,例如在GUI应用程序中,通常只有一个事件调度线程会访问某些部件,因此偏向锁能够有效地减少竞争和加锁解锁操作。

轻量级锁

在使用轻量级锁的情况下,当一个线程请求锁时,不会立即阻塞,而是判断该锁对象是否被其他线程持有。

  • 如果没有其他线程持有该锁对象,当前线程会将锁的对象头记录下来,并将对象头的标记设置为轻量级锁。这样,该线程就可以在接下来的操作中直接操作被锁定的对象,而无需进行互斥同步。
  • 如果在这个过程中发现有其他线程竞争锁对象,当前线程会尝试通过自旋来获取锁,而不是进行线程阻塞,从而减少了不必要的上下文切换和线程阻塞时间。

轻量级锁的优化策略适用于短期的同步操作,当竞争不激烈时,可以大幅度提高性能。但是当竞争激烈时,自旋操作可能会导致额外的开销,因此在这种情况下,轻量级锁会自动膨胀成重量级锁,使用传统的互斥同步方式。

重量级锁

重量级锁是一种传统的同步机制,用于实现多线程对共享资源的互斥访问。当多个线程竞争同一个锁对象时,只能有一个线程获取到锁,其他线程需要等待,直到锁被释放。

在使用重量级锁的情况下,当一个线程请求锁时,如果发现锁对象已被其他线程持有,则当前线程会被阻塞,进入等待状态,直到锁被释放。这种阻塞的机制会导致线程的上下文切换,增加了线程的延迟和系统开销,因此重量级锁在竞争激烈的情况下可能会影响性能。

重量级锁的实现通常需要依赖底层的操作系统特性,如互斥量、信号量等,以保证对共享资源的互斥访问。在JVM中,重量级锁是通过监视器(monitor)实现的,每个对象都会有一个对应的监视器,用于管理对该对象的同步访问。

尽管重量级锁在竞争激烈的情况下可能会带来性能开销,但它具有较强的功能和稳定性,适用于高并发场景中对资源访问的严格控制和保护。为了避免性能问题,使用重量级锁时应尽量减少锁的持有时间,避免不必要的阻塞。此外,Java中提供了一些其他的同步机制,如读写锁、可重入锁等,可以根据具体需求选择合适的锁机制来优化性能。

重量级锁、轻量级锁和偏向锁之间转换

img
img

该图主要是对上述内容的总结,如果对上述内容有较好的了解的话,该图应该很容易看懂。

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU。线程阻塞,响应时间缓慢。追求吞吐量。同步块执行速度较长。

synchronized的优化一览表

  • 锁的细粒度控制:可以尽量缩小 synchronized 关键字的范围,只对必要的代码块进行同步,这样可以减少线程间的竞争,提高并发性能。
  • 使用局部变量:如果在 synchronized 代码块内部使用局部变量而不是共享变量,可以减少对共享资源的访问次数,从而减少锁的竞争。
  • 使用同步块而不是同步方法:在某些情况下,使用同步块而不是同步方法可以提高性能。同步方法会对整个方法进行同步,而同步块可以控制同步范围更加细致。
  • 使用读写锁:如果共享资源在读操作上存在更多的并发访问,可以考虑使用读写锁来替代传统的 synchronized 锁,以提高并发读取的性能。
  • 使用 volatile 替代 synchronized:如果只需要保证可见性,而不需要互斥性,可以考虑使用 volatile 关键字来代替 synchronized,因为 volatile 有较低的开销。
  • 使用自旋锁:如果对共享资源的争用时间很短,可以考虑使用自旋锁来减少线程阻塞和切换的开销。自旋锁会让线程在一个循环内忙等待,直到获取到锁或达到一定的等待时间。
  • 适应性自旋(Adaptive Spinning):JDK 6 引入了适应性自旋的机制。当一个线程尝试获取锁时,如果发现该锁被其他线程持有,它会进行一段短暂的自旋(忙等待),而不是立即阻塞和切换线程。适应性自旋的时间会根据当前环境的运行态势自动调整。这种方式可以减少线程阻塞和切换的开销,适用于短暂的锁竞争。
  • 锁粗化(Lock Coarsening):JVM 可以将多个连续的加锁与解锁操作合并成一个更大的同步代码块,从而减少锁竞争的次数。这种优化技术可以避免频繁的锁释放和获取操作带来的开销。
  • 锁消除(Lock Elimination):JVM 对代码进行静态分析,发现某些对象不可能被其他线程访问到时,会进行锁消除的优化。这意味着可以将不必要的同步操作消除,从而减少了不必要的锁竞争。
  • 偏向锁(Biased Locking):JDK 5 引入了偏向锁的概念,它可以提升无竞争情况下的性能。偏向锁会在第一个线程获得锁后,将对象头中的标志位设置为偏向模式,并在之后的加锁解锁操作中免去互斥操作。这对于大多数情况下只有一个线程访问对象的场景非常有效。
  • 轻量级锁(Lightweight Locking):JDK 6 引入了轻量级锁机制,用于优化对同步块的访问。轻量级锁使用 CAS(比较并交换)指令来进行加锁和解锁操作,避免了线程阻塞和内核切换的开销。

这些优化技术在 JDK 中的实现使得 synchronized 关键字的性能有了较大的提升,使其成为高效的线程同步机制之一。