杰瑞科技汇

synchronized块如何实现线程安全与锁机制?

为什么需要 synchronized?—— 线程安全问题

在多线程环境下,当多个线程同时访问和修改同一个共享资源(如一个变量、一个对象)时,可能会发生不可预期的结果,这就是线程安全问题

synchronized块如何实现线程安全与锁机制?-图1
(图片来源网络,侵删)

核心原因:

  1. 原子性:一个操作(如 i++)在底层可能不是一条指令,而是“读取-修改-写入”三步,如果两个线程同时执行,可能会导致最终结果不正确。
  2. 可见性:一个线程对共享变量的修改,可能不会立即对其他线程可见,每个线程都有自己的工作内存,变量的值可能被缓存。

synchronized 的作用: 通过一种互斥锁的机制,确保在同一时间,只有一个线程可以进入被 synchronized 保护的代码块或方法,从而解决了上述的原子性和可见性问题。


synchronized 的两种用法

synchronized 可以用在两个地方:

  1. 实例方法
  2. 静态方法
  3. 代码块

代码块又分为:

synchronized块如何实现线程安全与锁机制?-图2
(图片来源网络,侵删)
  • 对当前实例对象加锁(this
  • 对指定对象加锁
  • 对 Class 对象加锁(用于静态方法和静态代码块)

下面我们逐一讲解。


synchronized 详细用法解析

A. synchronized 实例方法

synchronized 修饰一个非静态方法时,锁的是当前对象实例

public class Counter {
    private int count = 0;
    // 锁的是 this 对象,即 Counter 的实例
    public synchronized void increment() {
        count++;
    }
    public int getCount() {
        return count;
    }
}

工作原理:

  • 线程 A 调用 obj.increment(),它会获取 obj 这个对象的锁。
  • 在线程 A 释放锁之前(方法执行完毕或抛出异常),其他任何线程如果再调用 obj.increment() 或其他任何以 obj 为锁的 synchronized 方法,都会被阻塞,必须等待。
  • 锁是针对对象的,而不是针对代码的,同一个对象的不同 synchronized 方法共享同一把锁。

B. synchronized 静态方法

synchronized 修饰一个静态方法时,锁的是当前类的 Class 对象

public class Counter {
    private static int staticCount = 0;
    // 锁的是 Counter.class 对象
    public synchronized static void staticIncrement() {
        staticCount++;
    }
}

工作原理:

  • 线程 A 调用 Counter.staticIncrement(),它会获取 Counter.class 这个 Class 对象的锁。
  • 在线程 A 释放锁之前,其他任何线程调用 Counter.staticIncrement() 都会被阻塞。
  • 重要区别:静态方法的锁是类级别的,无论你创建了多少个 Counter 实例(new Counter()),它们共享同一个 Counter.class 对象,所有线程在访问静态 synchronized 方法时,都在竞争同一把锁。

C. synchronized 代码块

这是 synchronized 最灵活、也最推荐的方式,因为它可以精确地锁定需要同步的代码范围,而不是整个方法。

锁定当前实例对象 (this)

public class Counter {
    private int count = 0;
    public void increment() {
        // 只锁定这一小段代码,而不是整个方法
        synchronized (this) {
            count++;
        }
    }
}

这和 synchronized 实例方法的效果完全一样,但粒度更细。

锁定指定对象

你可以指定任意一个对象作为锁,这提供了极大的灵活性。

public class SharedObject {
    private int value = 0;
    private final Object lock = new Object(); // 专门用于同步的锁对象
    public void setValue(int value) {
        synchronized (lock) { // 锁定的是 lock 对象
            this.value = value;
        }
    }
    public int getValue() {
        synchronized (lock) { // 同一把锁
            return this.value;
        }
    }
}

最佳实践:

  • 不要用字符串(String)或基本类型包装类(如 Integer)作为锁,因为它们可能在 JVM 内部被重用或缓存,导致意想不到的锁竞争。
  • 通常使用一个专门声明为 private final 的对象作为锁,这能确保锁的唯一性和可控性。

锁定 Class 对象

对于需要同步静态资源的情况,使用代码块锁定 ClassName.class 是一个好选择。

public class Counter {
    private static int staticCount = 0;
    public static void staticIncrement() {
        synchronized (Counter.class) { // 锁定的是 Counter.class
            staticCount++;
        }
    }
}

这和 synchronized 静态方法的效果完全一样,但同样提供了更细的粒度。


synchronized 的底层原理(JDK 1.6 优化)

在早期版本,synchronized 是一个重量级锁,性能较差,但从 JDK 1.6 开始,JVM 对其进行了大量优化,引入了锁升级机制,使其性能大幅提升。

锁的状态分为四种,会随着竞争情况逐渐升级:

  1. 无锁状态
  2. 偏向锁:假设锁总是由同一个线程获取,当一个线程获取锁时,JVM 会在对象头里记录这个线程的 ID,之后这个线程再获取锁时,无需任何 CAS 操作,直接进入,速度非常快,适用于低竞争场景。
  3. 轻量级锁:当有另一个线程来竞争偏向锁时,偏向锁会升级为轻量级锁,竞争线程会通过自旋 的方式尝试获取锁,而不是立即阻塞,自旋就是在一个循环中不断检查锁是否被释放,适用于竞争短暂的场景。
  4. 重量级锁:如果自旋一定次数后仍然没有获取到锁,或者自旋线程数过多,锁就会升级为重量级锁,未获取到锁的线程会被阻塞,并进入等待队列,由操作系统内核进行调度,这是传统的锁机制,性能最差,但能保证公平性。

这个锁升级的过程是不可逆的,只能从低级到高级。


synchronizedjava.util.concurrent.locks.Lock 的对比

特性 synchronized java.util.concurrent.locks.Lock (如 ReentrantLock)
实现 JVM 关键字,是 Java 语法层面的内置特性 API 层面的实现,是一个接口
锁的获取 隐式获取,代码块或方法执行完毕自动释放 显式获取(lock())和释放(unlock()),通常在 finally 块中释放
锁类型 非公平锁(JDK 1.8 及以后) 可以是公平锁(new ReentrantLock(true))或非公平锁(默认)
功能 功能简单,只提供基本的互斥 功能强大,支持可中断的锁获取、超时获取锁、多条件变量Condition)等
性能 JDK 1.6 后优化良好,在低竞争下性能优异 在高竞争下,ReentrantLock 的性能通常优于 synchronized
使用建议 优先使用 synchronized,代码简洁,不易出错,在大多数场景下已经足够。 当需要 synchronized 无法提供的高级功能时(如公平锁、多条件变量),才考虑使用 Lock

总结与最佳实践

  1. 理解核心synchronized 通过互斥锁保证线程安全,解决了原子性和可见性问题。
  2. 掌握用法
    • 实例方法:锁 this
    • 静态方法:锁 Class 对象。
    • 代码块:最灵活,可以锁任意对象,推荐锁定一个专门的 private final 对象。
  3. 遵循原则
    • 尽量减小同步范围:只在必要时同步,同步范围越小越好,优先使用 synchronized 代码块而不是整个方法。
    • 避免锁竞争:高并发下,synchronized 可能会成为性能瓶颈,考虑优化算法或使用并发集合(如 ConcurrentHashMap)。
    • 不要用 String 或基本类型包装类做锁
    • 始终在 finally 功块中释放锁:对于 Lock 接口这是必须的,对于 synchronized 则是自动的,无需手动操作。
  4. 首选 synchronized:除非你有明确的、需要 ReentrantLock 等高级功能的理由,否则在并发编程中,synchronized 应该是你的首选,它简单、可靠,JVM 对它的优化已经非常成熟。
分享:
扫描分享到社交APP
上一篇
下一篇