Java并发编程——synchronized
本文最后更新于:4 年前
引言
并发编程的核心目标是让多个线程在同一程序中协作并行,提升整体执行效率,同时保证共享数据的正确性和一致性。在 Java 语言中,synchronized
关键字是实现线程同步最基础、最直观的方式之一。它可以保证同一时刻只有一个线程能执行被同步的代码(或方法),并在线程间建立起可见性保证,从而避免数据竞争和竞态条件。本篇文章将针对 synchronized
的使用场景、使用方式、底层原理及其对程序性能的影响进行系统的说明,并通过示例展示为什么需要同步以及如何正确使用 synchronized
。
概念
synchronized
是Java中用于实现线程同步的关键字,它确保在多线程环境中对共享资源的安全访问。通过使用 synchronized
将修改共享资源的代码部分置于临界区,可以防止多个线程同时访问同一资源,从而避免数据竞争、竞态条件以及其他并发问题。
synchronized
是一种互斥锁,它保证:
- 互斥性:确保同一时间只有一个线程可以执行被同步的代码块或方法。
- 内存可见性:保证一个线程对共享变量的修改对其他线程是可见的。
工作原理
synchronized
可以用于方法或代码块,其工作原理和功能反映了管程的特性:
- 互斥:确保同一时刻只有一个线程可以执行同步的代码块。
- 等待/通知机制:提供了一种让线程等待某个条件的方法,并在条件满足时接收通知继续执行。
(在下一章节中,会详细的对管程展开说明)
使用
同步方法
通过在方法声明上添加 synchronized
关键字,使整个方法成为同步方法。同步方法的锁对象取决于方法是静态的还是实例的:
- 实例方法:锁对象为当前实例(this)。
- 静态方法:锁对象为当前类的
Class
对象。
代码示例
1 |
|
特点
- 简单易用:无需显式指定锁对象。
- 锁粒度大:整个方法被锁住,可能导致不必要的性能开销。
同步代码块
通过在方法内部使用 synchronized
代码块,可以更精细地控制锁的范围和粒度。同步代码块允许指定一个锁对象,提供更灵活的同步机制。
语法
1 |
|
代码示例
1 |
|
特点
- 更细粒度:只锁定必要的代码段,减少锁的持有时间,提高性能。
- 灵活性:可以选择不同的锁对象,实现更复杂的同步需求。
锁的类型
对象锁
每个 Java 对象都有一个关联的锁,不同实例之间互不影响。当一个线程进入一个被 synchronized
修饰的实例方法或同步代码块时,它必须持有该对象的锁。
1 |
|
类锁
类锁是与类相关联的锁,所有实例共享,同一时间只能有一个线程持有类锁。它通过 synchronized
静态方法或同步类对象实现。
1 |
|
可重入
概念
可重入指的是同一个线程可以多次获取同一把锁,而不会导致死锁。这意味着如果一个线程已经持有了某个锁,它可以再次获取该锁而不会被阻塞。
Java的 synchronized
机制天然支持重入锁。当一个线程已经持有对象锁时,可以在同一个线程中再次获取该锁。
代码示例
1 |
|
性能考虑
锁的粒度
- 大粒度锁:锁定较大的代码区域或整个方法,简单但可能导致更多的线程等待,影响并发性能。
- 小粒度锁:锁定较小的代码段,减少锁的持有时间,提高并发度,但增加了实现的复杂性。
锁的竞争
高竞争环境下,多个线程争夺同一锁,可能导致频繁的上下文切换和性能下降。
优化策略
- 减少锁的持有时间:尽量缩小
synchronized
代码块的范围,仅包含必要的同步操作。 - 使用更高效的锁机制:在高并发场景下,考虑使用
ReentrantLock
或Atomic
类提供的原子操作(后续章节会提及)。 - 避免死锁:设计合理的锁获取顺序,避免多个锁的循环依赖。
问题引入
程序
两个线程对初始值为 0 的变量分别做 10000 次的自增和自减操作。
1 |
|
运行程序,发现控制台每次输出的结果是不确定的,0、正数、负数都可能出现。
字节码分析
查看 MyCounter
方法的字节码
increase
1 |
|
decrease
1 |
|
解读:
- aload_0(0):将当前对象 (
this
) 压入操作栈。 - dup(1):复制栈顶的对象引用,确保后续操作仍能引用
this
。 - getfield #3(2):获取
this.count
的当前值,并压入栈。 - iconst_1(5):将整数1压入栈。
- iadd(6):将栈顶的两个整数相加(
count + 1
),结果压入栈。 - isub(6):将栈顶的两个整数相减(
count - 1
),结果压入栈。 - putfield #3(7):将相加后的结果存回
this.count
。 - return(10):方法返回。
运行结果分析
结果不确定性原因分析
- 非原子性操作:
increase
和decrease
方法中的操作并不是原子性的,它们涉及多个步骤(加载、计算、存储)。 - 多线程交叉执行:在多线程环境下,这些步骤可能被不同线程交叉打断,导致某些操作未被正确执行或被覆盖,最终结果不符合预期。
总结:count
是共享资源,而 increase
和 decrease
方法包含对 count
的非原子性操作,同时也并非作为临界区,当两个线程 t1
和 t2
并发执行时,可能导致竞态条件,最终导致了程序结果的不确定性。
预想情况 0
- t1 获取 count
- t1 将 count 自增
- t1 将 count 写回内存
- 上下文切换
- t2 获取 count
- t2 将 count 自减
- t2 将 count 写回内存
- 主线程获取最终结果为 0
sequenceDiagram
participant 主线程
participant t1
participant t2
participant MyCounter
%% t1 自增操作
t1->>MyCounter: get count (0)
t1->>t1: count = count + 1
t1->>MyCounter: set count = 1
%% t2 自减操作
t2->>MyCounter: get count (1)
t2->>t2: count = count - 1
t2->>MyCounter: set count = 0
%% 主线程获取最终结果
主线程->>MyCounter: get count (0)
主线程->>主线程: 打印 "最终计数结果: 0"
正数
- t1 获取 count
- t1 将 count 自增
- 上下文切换
- t2 获取 count
- t2 将 count 自减
- t2 将 count 写回内存
- 上下文切换
- t1 将 count 写回内存
- 主线程获取最终结果为正数
sequenceDiagram
participant 主线程
participant t1
participant t2
participant MyCounter
%% t1 自增操作
t1->>MyCounter: get count (0)
t1->>t1: count = count + 1
%% t2 自减操作
t2->>MyCounter: get count (0)
t2->>t2: count = count - 1
t2->>MyCounter: set count = -1
%% t1 写回操作
t1->>MyCounter: set count = 1
%% 主线程获取最终结果
主线程->>MyCounter: get count (1)
主线程->>主线程: 打印 "最终计数结果: 正数"
负数
- t1 获取 count
- t1 将 count 自增
- 上下文切换
- t2 获取 count
- t2 将 count 自减
- 上下文切换
- t1 将 count 写回内存
- 上下文切换
- t2 将 count 写回 内存
- 主线程获取最终结果为负数
sequenceDiagram
participant 主线程
participant t1
participant t2
participant MyCounter
%% t1 自增操作
t1->>MyCounter: get count (0)
t1->>t1: count = count + 1
%% t2 自减操作
t2->>MyCounter: get count (0)
t2->>t2: count = count - 1
%% t1 写回操作
t1->>MyCounter: set count = 1
%% t2 写回操作
t2->>MyCounter: set count = -1
%% 主线程获取最终结果
主线程->>MyCounter: get count (-1)
主线程->>主线程: 打印 "最终计数结果: 负数"
上述代码中,increase
和 decrease
方法中包含对共享资源 count
的修改操作(即 count++
和 count--
),这些操作应该被置为临界区中的操作。
1 |
|
小结
在多线程环境中,多个线程可能会同时访问和修改共享资源(如变量、对象)。如果不加以控制,这可能导致数据不一致或程序行为不可预测。为了确保线程安全,必须控制线程对共享资源的访问顺序和方式。下面将介绍如何控制多线程管理共享资源的正确方式。
问题解决
下面将通过 synchronized
解决上面提出的同步问题,有以下两种方式:
对象锁
在方法中使用 synchronized
修饰符,或显示地增加锁对象,让所有线程对同个 MyCounter
示例的 count
操作都是互斥的。
1 |
|
1 |
|
类锁
对象锁升级为类锁,让所有线程对所有 MyCounter
示例的 count
操作都是互斥的。
1 |
|
类锁的粒度过大,不推荐使用。
死锁(Deadlock)
当两个或多个线程互相等待对方持有的锁时,导致所有线程都无法继续执行。
预防策略
- 锁顺序:所有线程以相同的顺序获取多个锁。
- 锁超时:使用
ReentrantLock
的tryLock
方法,设置获取锁的超时时间。 - 最小化锁持有时间:尽量减少同步代码块内的操作,避免长时间持有锁。
代码示例
1 |
|
在上述示例中,如果线程1执行method1
并持有lockA
,同时线程2执行method2
并持有lockB
,然后都尝试获取对方的锁,将导致死锁。
其他
关于死锁的其他内容,会在后续章节中提及。
注意事项及最佳实践
- 锁范围尽量小:使用代码块同步而不是整个方法,减少锁的持有时间。
- 避免死锁:避免线程间相互持有对方需要的锁,谨慎使用多个锁,保持锁获取顺序一致。
- 避免不必要的同步:对于只读操作或线程安全的类(如
ConcurrentHashMap
),无需使用同步,后续章节会详细说明。 - 使用合适的锁机制:对于复杂同步需求,优先考虑
ReentrantLock
或java.util.concurrent
包中的工具类,后续章节会详细说明。 - 锁对象应明确:使用私有的锁对象而非
this
,避免外部代码干扰。
总结
synchronized
是 Java 并发编程中最基础的同步工具之一,它通过互斥性和内存可见性两个方面保证了多线程访问共享资源的安全。开发者可以使用同步方法或同步代码块来灵活控制锁的粒度,减少竞态条件带来的不确定性。在选择锁的类型时,需要充分评估应用场景并综合考虑性能和安全性。
此外,还需警惕死锁和不必要的锁竞争,通过控制锁的持有时间、合理的锁粒度以及一致的锁获取顺序来预防同步隐患。充分理解 synchronized
的特性并结合具体业务需求选择合适的并发工具,才能编写出安全、稳定且高效的多线程程序。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!