Java synchronized入门指南 – wiki基地


Java synchronized 入门指南:深入理解线程同步利器

1. 引言:多线程世界的挑战

现代计算机系统普遍采用多核处理器,为了充分利用计算资源,并发编程和多线程技术变得越来越重要。Java 作为一门诞生之初就支持多线程的语言,提供了丰富的并发工具。然而,多线程并非没有代价。当多个线程访问和修改共享数据时,如果没有恰当的控制,就可能发生各种意想不到的问题,最常见的就是竞态条件(Race Condition)

考虑一个简单的场景:两个线程同时尝试对同一个变量进行自增操作。理想情况下,如果变量初始值为 0,两个线程各执行一次自增,结果应该是 2。但在并发环境下,由于线程执行的时序不确定,可能会出现以下情况:

  1. 线程 A 读取变量值(0)。
  2. 线程 B 读取变量值(0)。
  3. 线程 A 将值加 1(得到 1)。
  4. 线程 B 将值加 1(得到 1)。
  5. 线程 A 将新值写入变量(变量变为 1)。
  6. 线程 B 将新值写入变量(变量变为 1)。

最终结果是 1,而不是期望的 2。这就是一个典型的竞态条件,因为最终结果取决于线程执行的时序,而这个时序是不确定的。

为了解决这类问题,我们需要一种机制来协调多个线程对共享资源的访问,确保在同一时刻只有一个线程能够执行对共享数据的关键操作。这种机制称为线程同步(Thread Synchronization)

Java 提供了多种线程同步机制,其中最基本、最常用的就是 synchronized 关键字。本篇文章将带你深入理解 synchronized 的工作原理、使用方式以及相关概念。

2. synchronized 的作用:互斥与可见性

synchronized 关键字在 Java 中主要有两个核心作用:

  1. 互斥(Mutual Exclusion):确保同一时刻只有一个线程能够进入由 synchronized 保护的特定代码块或方法。这有效地避免了多个线程同时修改共享数据导致的竞态条件。当一个线程进入 synchronized 区域时,它会获得一个锁(也称为监视器锁或内置锁),其他试图进入该区域的线程必须等待该锁的释放。
  2. 可见性(Visibility):确保一个线程对共享变量的修改对于随后获得同一个锁的其他线程是可见的。这遵循 Java 内存模型(Java Memory Model, JMM)的规定。当线程释放锁时,它会将自己的工作内存中的修改刷新到主内存;当线程获取锁时,它会清空自己的工作内存,从主内存中读取最新的共享变量值。

理解这两点至关重要。互斥解决了“写”的冲突,避免了数据损坏;可见性解决了“读写”的冲突,确保了线程看到的是最新的数据。

3. 如何使用 synchronized:三种形式

synchronized 关键字可以应用于方法或代码块。根据其修饰的对象不同,可以分为以下几种形式:

3.1 同步方法(Synchronized Methods)

synchronized 修饰一个实例方法时,它会锁定该方法所属的对象实例。这意味着,如果一个对象有一个同步方法,那么同一时刻,只有一个线程能够执行这个对象的任何一个同步实例方法(包括不同的同步实例方法,因为它们争夺的是同一个对象的锁)。

“`java
public class SynchronizedMethodExample {
private int counter = 0;

// 修饰实例方法
public synchronized void increment() {
    counter++;
    System.out.println(Thread.currentThread().getName() + " incremented to " + counter);
}

// 另一个同步实例方法
public synchronized void decrement() {
    counter--;
    System.out.println(Thread.currentThread().getName() + " decremented to " + counter);
}

// 普通实例方法,不受锁控制
public int getCounter() {
    return counter;
}

public static void main(String[] args) throws InterruptedException {
    SynchronizedMethodExample example = new SynchronizedMethodExample();

    Runnable incrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
            example.increment();
        }
    };

    Runnable decrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
            example.decrement();
        }
    };

    Thread thread1 = new Thread(incrementTask, "Thread-Inc-1");
    Thread thread2 = new Thread(incrementTask, "Thread-Inc-2");
    Thread thread3 = new Thread(decrementTask, "Thread-Dec-1");

    thread1.start();
    thread2.start();
    thread3.start();

    thread1.join();
    thread2.join();
    thread3.join();

    // 期望结果: 1000 + 1000 - 1000 = 1000
    System.out.println("Final counter value: " + example.getCounter());
}

}
“`

在上面的例子中,increment()decrement() 方法都被 synchronized 修饰。这意味着,当 Thread-Inc-1 调用 example.increment() 时,它获取了 example 对象的锁。在 Thread-Inc-1 释放锁之前,Thread-Inc-2Thread-Dec-1 如果也试图调用 example 对象的 increment()decrement() 方法,它们都将被阻塞,直到 Thread-Inc-1 执行完毕并释放锁。最终,尽管有多个线程操作,counter 的值会正确地反映所有操作的总和。

注意: 如果创建了 SynchronizedMethodExample 的多个实例,那么不同实例之间的同步方法调用是互不影响的,因为它们锁的是不同的对象实例。

3.2 同步静态方法(Synchronized Static Methods)

synchronized 修饰一个静态方法时,它会锁定该方法所属的类的 Class 对象。因为一个类在内存中只有一个 Class 对象,所以同一时刻,无论创建了多少个该类的实例,甚至没有创建任何实例,最多只有一个线程能够执行该类的任何一个同步静态方法。

“`java
public class SynchronizedStaticMethodExample {
private static int staticCounter = 0; // 静态变量,所有对象共享

// 修饰静态方法
public static synchronized void incrementStatic() {
    staticCounter++;
    System.out.println(Thread.currentThread().getName() + " incremented static to " + staticCounter);
}

// 另一个同步静态方法
public static synchronized void decrementStatic() {
    staticCounter--;
    System.out.println(Thread.currentThread().getName() + " decremented static to " + staticCounter);
}

public static int getStaticCounter() {
    return staticCounter;
}

public static void main(String[] args) throws InterruptedException {
    // 不需要创建对象实例就可以访问静态方法
    Runnable incrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
            SynchronizedStaticMethodExample.incrementStatic();
        }
    };

    Runnable decrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
            SynchronizedStaticMethodExample.decrementStatic();
        }
    };

    Thread thread1 = new Thread(incrementTask, "Thread-Static-Inc-1");
    Thread thread2 = new Thread(incrementTask, "Thread-Static-Inc-2");
    Thread thread3 = new Thread(decrementTask, "Thread-Static-Dec-1");

    thread1.start();
    thread2.start();
    thread3.start();

    thread1.join();
    thread2.join();
    thread3.join();

    // 期望结果: 1000 + 1000 - 1000 = 1000
    System.out.println("Final static counter value: " + SynchronizedStaticMethodExample.getStaticCounter());
}

}
“`

这里的 incrementStatic()decrementStatic() 都锁定了 SynchronizedStaticMethodExample.class 对象。因此,无论哪个线程调用这些静态方法,它们都在争夺同一个锁,从而保证了 staticCounter 这个共享静态变量的正确性。

3.3 同步代码块(Synchronized Blocks)

同步方法锁定的是整个方法体,而同步代码块则允许你指定需要同步的代码范围以及用于同步的锁对象。这提供了更细粒度的控制。同步代码块的语法是:

java
synchronized (lockObject) {
// 需要同步的代码
}

这里的 lockObject 是一个对象引用。当线程执行到这个同步块时,它会尝试获取 lockObject 的锁。只有获取到锁的线程才能进入代码块。lockObject 可以是:

  • this:锁定当前对象实例,与同步实例方法等效。
  • 某个实例变量:锁定特定的对象实例作为锁。通常推荐使用一个 private final Object 类型的变量作为锁对象,而不是业务数据对象,以避免外部意外地获取到锁。
  • 类名.class:锁定类的 Class 对象,与同步静态方法等效。

使用 this 作为锁:

“`java
public class SynchronizedBlockThisExample {
private int counter = 0;

public void increment() {
    // 锁定当前对象实例
    synchronized (this) {
        counter++;
        System.out.println(Thread.currentThread().getName() + " incremented to " + counter);
    }
    // 同步块外部的代码可以并发执行
    // System.out.println(Thread.currentThread().getName() + " is outside the synchronized block.");
}

public int getCounter() {
    return counter;
}

// ... main 方法与 SynchronizedMethodExample 类似,使用此类的实例调用 increment() ...

}
“`
这种方式与同步实例方法的功能相同,但可以只同步方法中的一部分代码。

使用特定对象作为锁(推荐方式之一):

“`java
public class SynchronizedBlockObjectExample {
private int counter = 0;
// 专门用于同步的锁对象
private final Object counterLock = new Object();

public void increment() {
    // 锁定 counterLock 对象
    synchronized (counterLock) {
        counter++;
        System.out.println(Thread.currentThread().getName() + " incremented to " + counter);
    }
}

// 如果有其他需要同步但与 counter 无关的操作,可以使用另一个锁对象
private final Object anotherLock = new Object();
public void someOtherSynchronizedOperation() {
     synchronized (anotherLock) {
         // ... 保护 other shared state ...
     }
}


public int getCounter() {
    return counter;
}

 public static void main(String[] args) throws InterruptedException {
    SynchronizedBlockObjectExample example = new SynchronizedBlockObjectExample();

    Runnable incrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
            example.increment();
        }
    };

    Thread thread1 = new Thread(incrementTask, "Thread-ObjLock-1");
    Thread thread2 = new Thread(incrementTask, "Thread-ObjLock-2");

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Final counter value: " + example.getCounter()); // 期望结果: 2000
}

}
``
使用独立的
private final Object` 作为锁对象是推荐的做法,因为它提高了封装性。锁对象是私有的,外部代码无法获取并锁定它,从而避免了死锁或意外的同步行为。同时,如果一个类中有多个需要同步的独立操作,可以使用不同的锁对象来避免不必要的阻塞(例如,一个操作修改 A,另一个操作修改 B,如果它们使用不同的锁,可以并发执行)。

使用 类名.class 作为锁:

“`java
public class SynchronizedBlockClassExample {
private static int staticCounter = 0;

public void incrementStatic() {
    // 锁定类的 Class 对象
    synchronized (SynchronizedBlockClassExample.class) {
        staticCounter++;
        System.out.println(Thread.currentThread().getName() + " incremented static to " + staticCounter);
    }
}

public static int getStaticCounter() {
    return staticCounter;
}

// ... main 方法与 SynchronizedStaticMethodExample 类似,可以创建多个实例调用 incrementStatic() ...
 public static void main(String[] args) throws InterruptedException {
    // 可以通过实例或直接通过类调用(尽管同步块在非静态方法中)
    SynchronizedBlockClassExample example1 = new SynchronizedBlockClassExample();
    SynchronizedBlockClassExample example2 = new SynchronizedBlockClassExample();


    Runnable incrementTask = () -> {
        for (int i = 0; i < 1000; i++) {
             // 无论是通过哪个实例调用,它们锁定的都是同一个 Class 对象
             example1.incrementStatic(); // 或者 example2.incrementStatic();
        }
    };

    Thread thread1 = new Thread(incrementTask, "Thread-ClassLock-1");
    Thread thread2 = new Thread(incrementTask, "Thread-ClassLock-2");

    thread1.start();
    thread2.start();

    thread1.join();
    thread2.join();

    System.out.println("Final static counter value: " + SynchronizedBlockClassExample.getStaticCounter()); // 期望结果: 2000
}

}
“`
这种方式与同步静态方法的功能相同,锁定的是类的 Class 对象。这意味着,无论创建了多少个该类的实例,甚至通过不同实例的同步代码块访问,它们都在争夺同一个锁。

总结 synchronized 的锁对象:

synchronized 修饰 锁对象 影响范围
实例方法 当前实例对象 (this) 同一实例的其他同步实例方法
静态方法 类的 Class 对象 (类名.class) 同一个类的其他同步静态方法
代码块 (synchronized(obj)) 指定的对象 (obj) 使用同一个对象 (obj) 作为锁的同步块/方法
代码块 (synchronized(this)) 当前实例对象 (this) 同一实例的其他同步实例方法或 synchronized(this)
代码块 (synchronized(类名.class)) 类的 Class 对象 (类名.class) 同一个类的其他同步静态方法或 synchronized(类名.class)

4. synchronized 的底层原理:监视器锁(Monitor)

在 JVM 内部,synchronized 是基于监视器锁(Monitor)实现的。每个 Java 对象都可以拥有一个监视器。当线程进入一个同步方法或同步块时,它会尝试获取该对象的监视器锁。

  • 对于同步实例方法或 synchronized(this) 块,锁是当前实例对象的监视器。
  • 对于同步静态方法或 synchronized(类名.class) 块,锁是该类 Class 对象的监视器。
  • 对于 synchronized(obj) 块,锁是指定对象 obj 的监视器。

当一个线程成功获取到监视器锁后,其他试图获取同一个监视器锁的线程将被阻塞,进入等待队列(Entry Set)。

当持有锁的线程退出同步区域(无论是正常完成、抛出异常还是调用 wait() 方法)时,它会释放监视器锁。此时,等待队列中的线程会竞争重新获取锁。

wait() / notify() / notifyAll()synchronized 的关系:

Object 类中提供了 wait()notify()notifyAll() 方法,它们是实现线程间协作(例如生产者-消费者问题)的关键。这些方法必须在已经获取了对象监视器锁的同步块或同步方法中使用,否则会抛出 IllegalMonitorStateException

  • wait():线程释放当前持有的锁,并进入该对象的等待池(Wait Set),直到被 notify()notifyAll() 唤醒,或等待超时。被唤醒后,线程必须重新竞争该对象的锁才能继续执行。
  • notify():唤醒该对象等待池中的一个随机线程。
  • notifyAll():唤醒该对象等待池中的所有线程。

这些方法允许线程在某个条件不满足时暂时释放锁并休眠,等待其他线程改变条件后再被唤醒继续执行,这是一种更高级的线程协作模式,通常与 synchronized 结合使用。

5. synchronized 的重要特性

5.1 重入性(Reentrancy)

synchronized 锁是可重入的。这意味着,如果一个线程已经持有了某个对象的锁,那么它可以再次进入该对象的任何其他同步方法或同步块,而不会被自己阻塞。

考虑以下例子:

“`java
public class ReentrantExample {

public synchronized void method1() {
    System.out.println(Thread.currentThread().getName() + " entered method1");
    method2(); // 调用另一个同步方法
    System.out.println(Thread.currentThread().getName() + " exiting method1");
}

public synchronized void method2() {
    System.out.println(Thread.currentThread().getName() + " entered method2");
    // 这里可以再次进入 synchronized(this) 块
    synchronized (this) {
         System.out.println(Thread.currentThread().getName() + " entered inner synchronized block");
    }
    System.out.println(Thread.currentThread().getName() + " exiting method2");
}

public static void main(String[] args) {
    ReentrantExample example = new ReentrantExample();
    new Thread(() -> example.method1(), "ReentrantThread").start();
}

}
“`

ReentrantThread 调用 method1() 时,它获取了 example 对象的锁。在 method1() 中,它又调用了 method2()。因为 method2() 也是同步方法,并且锁的是同一个对象 (example 对象),如果锁不是可重入的,该线程就会自己阻塞自己,导致死锁。但实际上,JVM 知道当前线程已经持有 example 对象的锁,因此允许它再次进入 method2()。同样,在 method2() 内部的 synchronized(this) 块也可以顺利进入。

重入性是避免自死锁的关键特性,使得开发者可以更自然地编写互相调用的同步方法。

5.2 可见性保证

如前所述,synchronized 提供了可见性保证。当一个线程释放锁时,它会将其在工作内存中的修改刷新到主内存。当另一个线程获取同一个锁时,它会使自己的工作内存失效,强制从主内存中读取最新的变量值。

这在 Java 内存模型中对应于一个“happens-before”关系:一个线程对一个变量的写操作happens-before后续线程对同一个锁的获取,以及后续线程对同一个变量的读操作。简单来说,释放锁前的写操作对获取同一个锁后的读操作是可见的

6. synchronized 的潜在问题与局限性

虽然 synchronized 强大且易于使用,但它并非没有缺点或局限性:

  1. 性能开销: 锁的获取和释放需要消耗 CPU 资源。在竞争激烈的情况下,频繁的加锁和解锁操作可能会成为性能瓶颈。JVM 在较新的版本中对 synchronized 进行了优化(如偏向锁、轻量级锁、重量级锁),但在高并发、短时同步的场景下,其开销仍然可能大于一些更现代的并发工具(如原子变量)。
  2. 阻塞: synchronized 是一种悲观锁。当一个线程获取锁时,其他试图获取锁的线程会被完全阻塞(挂起),直到锁被释放。这可能导致线程上下文切换的开销,并且降低了程序的响应性。
  3. 无法中断等待锁的线程: 一旦一个线程被阻塞在 synchronized 锁的获取上,它是无法被中断的。它会一直等待,直到获取到锁。这与 java.util.concurrent.locks.Lock 接口提供的可中断锁 (lockInterruptibly()) 不同。
  4. 无法尝试获取锁: synchronized 没有提供非阻塞的方式来尝试获取锁(例如 tryLock())。线程要么获取锁,要么被阻塞。这也限制了它的灵活性。
  5. 粒度问题: 同步方法锁定了整个方法,可能导致锁的范围过大,降低了并发度。同步代码块虽然提供了细粒度控制,但如果锁定的对象选择不当,仍然可能引起问题。
  6. 死锁风险: 如果多个线程需要获取多个不同的锁,并且它们以不同的顺序获取锁,就可能发生死锁。synchronized 本身无法自动检测或解决死锁。

7. 替代方案简述(了解)

对于 synchronized 的一些局限性,Java 的 java.util.concurrent 包提供了更灵活、更强大的并发工具:

  • java.util.concurrent.locks.Lock 接口及其实现(如 ReentrantLock): 提供了比 synchronized 更灵活的锁操作,如可中断锁、公平/非公平锁、尝试获取锁(tryLock())等。
  • java.util.concurrent.atomic 包中的原子变量类(如 AtomicInteger): 对于单个变量的原子操作(如自增、自减、比较并交换 CAS),原子类提供了无锁(Lock-Free)或 CAS 实现的高效解决方案,通常比使用 synchronized 保护这些简单操作性能更好。
  • java.util.concurrent.SemaphoreCountDownLatchCyclicBarrier 等: 用于更复杂的线程协作和控制。

这些工具提供了更高级的并发控制手段,但在许多简单的同步场景下,synchronized 仍然是足够且最简单方便的选择。

8. 使用 synchronized 的最佳实践

  • 只同步必要的部分(缩小同步粒度):synchronized 应用于最小的代码范围,即只保护对共享变量进行读写的关键区域(Critical Section)。不要同步整个方法,如果只有几行代码需要同步。
  • 理解锁定的对象: 清楚地知道你的 synchronized 锁的是哪个对象(this、Class 对象还是特定的锁对象)。这对于避免意外的死锁或同步不足至关重要。
  • 优先使用私有的 final Object 作为锁: 对于同步代码块,尽量避免使用字符串常量、包装类对象、或者业务对象作为锁。字符串常量池可能导致意外共享锁;包装类对象可能因为装箱拆箱产生不同的对象;业务对象可能被外部代码获取并锁定,导致不可预测的行为。一个 private final Object lock = new Object(); 是一个安全且推荐的锁对象。
  • 避免在同步区域内执行耗时操作: IO 操作(文件读写、网络通信)、复杂的计算等耗时操作应尽量放在同步区域之外,以减少锁的持有时间,提高并发性能。
  • 谨慎使用 wait() / notify() 如果需要线程协作,确保在同步区域内调用这些方法,并且理解它们的工作原理(wait() 释放锁,notify() / notifyAll() 不释放锁)。

9. 总结

synchronized 是 Java 中用于实现线程同步的最基本和最重要的关键字。它通过监视器锁机制提供了互斥性和可见性保证,有效地解决了多线程环境下共享数据的安全问题。

我们学习了 synchronized 的三种主要使用形式:同步实例方法、同步静态方法和同步代码块,并理解了它们各自锁定的对象和作用范围。同时,我们探讨了 synchronized 的可重入性、与 wait() / notify() 的关系,以及它可能带来的性能开销和死锁风险。

作为 Java 并发编程的基石,熟练掌握 synchronized 是非常重要的。对于更复杂的并发场景,可以进一步学习 java.util.concurrent 包提供的更高级工具。但在许多情况下,正确地使用 synchronized 就足以保证线程安全。

通过理解 synchronized 的原理和特性,并在实践中遵循最佳实践,你可以有效地编写健壮、安全的并发 Java 应用程序。


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部