Advertisement

synchronized不能锁静态变量_你用对锁了吗?浅谈 Java “锁” 事

阅读量:

并发 BUG 的源头

人们都知道电脑配置通常包括CPU、内存和硬盘等主要组件;在这些组件中,硬盘是最慢的;接着是内存,在其运行中也表现较弱;然而,在某些情况下(例如数据传输速率),内存的速度仍远低于CPU运行的速度;为了提高系统的整体性能(特别是多任务处理能力),人们设计出了CPU缓存机制;该机制通过分层存储(如L1、L2、L3缓存)来优化数据访问效率)。

正是这个CPU缓存再加上现在多核CPU的情况产生了并发BUG

6c662636eb1b904e9420dd2cad722ab8.png

这段代码非常基础。假设此时有两个线程A和B分别运行于CPU-A和CPU-B上,在执行该方法时它们会依次将变量a从主存储存在各自的缓存中。值得注意的是此时这两个缓存中的a值均为零。

然后它们依次执行a++操作;此时它们各自都看到a的值已经是1;随后将a传输至主存空间时发现其值仍为1;这就导致了一个问题:虽然进行了两次加一操作;但最终的结果却仍然是1而非预期的2。

这个问题就叫可见性问题

关注我们 a++ 这个语句,在编程中我们会经常遇到这样的情况:我们的当前使用的编程语言多属于高级语言范畴。实际上这也与所谓的语法糖概念非常相似,在一定程度上提高了代码的可读性与简洁性。看起来使用起来确实非常方便,并且从表面上看只需要编写少量代码即可完成复杂功能。但实际运行时所涉及的指令一条都不能省略。

在高级语言中的一条语句被转换成CPU指令时,并非只有一条指令对应;举个例子来说,在像C这样的高级语言中执行一条语句可能会生成多条CPU指令来完成同样的功能。例如a++这样的操作转换成CPU指令后会总共有至少三条不同的指令。

  • 把 a 从内存拿到寄存器中;
  • 在寄存器中 +1;
  • 将结果写入缓存或内存中;

基于此,在分析并发BUG时我们通常会关注以下三个关键问题:原子性缺失、指令重排以及执行顺序混乱。原子性缺失指的是CPU在切换上下文时可能中断对共享变量的操作序列而导致数据不一致的问题;而指令重排则指编译器或解释器为了提高性能而打乱程序原本的操作顺序;有序性问题则源于CPU在某些特定情况下(如等待内存加载)会颠倒操作顺序以优化执行效率。这些问题的存在本质上反映了系统设计中对性能提升所付出的一些必要代价;因此我们不得不直面这些挑战并寻找解决方案。今天我们将重点探讨解决这些问题的关键技术——互斥机制及其实现方式之一——锁机制

互斥机制的核心思想在于确保对共享资源的操作具有严格的独占性,在任何时刻只能允许一个线程对共享资源进行操作;换句话说就是实现一种"公平竞争"机制以防止资源冲突的发生。这一概念最早由计算机科学领域的先驱提出并得到了广泛应用;其中最著名的代表就是"mutex"(互斥锁)这一数据结构;它正是解决这种同步难题的关键工具

****

开发者在学习Java时可能自然会联想到synchronized关键字这一机制的存在性问题因为它是由语言层面直接支持的。为了更好地理解synchronized关键字我们将重点分析这一机制的特点及其应用场景同时也会探讨开发过程中可能出现的一些常见问题并逐一解答以帮助大家更加深入地掌握这一知识点。

****

synchronized 注意点

让我们深入了解一下这份代码。这段代码正是我们通往加薪之路的一个重要步骤。其中一百万其实是通过水循环得以实现的。通过观察不同线程间的工资水平是否平衡,我们可以深入探讨这个流式处理的核心逻辑:IntStream.rangeClosed(1,10点钟).forEach实际上是相当于执行了一次规模宏大的for循环操作。对于不熟悉这一概念的人来说,在这里可能会有一些疑惑和不解。

4fe47c195c91a6c581a9c3735e606372.png

你可以试着自己分析一下;再想想看是否存在问题?初步看来似乎没有问题;只有一个线程在运行;没有修改值的操作;看上去似乎没有明显的错误?无须担心多线端之间的竞争;也通过 volatile 标记来确保数据可见性;让我们来看一下结果吧;我截取了一部分内容供参考

75c635bd8016370cb46d7ea8b59f7c90.png

可以看到首先有 log 打出来就已经不对了,其次打出来的值竟然还相等 !有没有出乎你的意料之外?有同学可能下意识就想到这就 raiseSalary 在修改,所以肯定是线程安全问题来给 raiseSalary 加个锁! 请注意只有一个线程在调用 raiseSalary 方法,所以单给 raiseSalary 方法加锁并没啥用。 这其实就是我上面提到的原子性问题,想象一下涨工资线程在执行完 yesSalary++ 还未执行 yourSalary++ 时,比工资线程刚好执行到 yesSalary != yourSalary 是不是肯定是 true ?所以才会打印出 log。 再者由于用 volatile 修饰保证了可见性,所以当打 log 的时候,可能 yourSalary++ 已经执行完了,这时候打出来的 log 才会是 yesSalary == yourSalary 。 所以最简单的解决办法就是把 raiseSalary()compareSalary() 都用 synchronized 修饰,这样涨工资和比工资两个线程就不会在同一时刻执行,因此肯定就安全了!

b22ab0b903a8cd3beba321b43444a0a2.png

看起来锁的整体结构看似并不复杂,但实际应用中使用synchronized机制可能会遇到一些挑战。具体来说,我们需要深入理解synchronized锁的作用机制和工作原理。举个例子,在某些场景下将逻辑拆解为多线程处理能够显著提升性能。值得注意的是,在Java中实现并行处理通常会采用ForkJoinPool线程池模式,默认情况下其核心线程数量与CPU核数相匹配。

4e8418576f69d51842b4772498365230.png

因为 raiseSalary() 被加了锁的原因导致结果正确,在此情况下可以看出 synchronized 关联的是同一个 yesLockDemo 实例,在我们的 main 程序中只有一个实例存在。因此,在多线程环境中多个 thread 同时竞争同一把锁导致最终计算出来的数据是正确的。那么为了进一步优化代码逻辑使得每个 thread 能够独立操作避免竞争问题我可以考虑对代码进行如下修改:让每个线程拥有独立的 yesLockDemo 实例来进行工资提升操作

82511c46c5efa2594546ff503de7e1e7.png

你是否发现此锁已失效?然而,在本例中,预期的百万年薪因错误而降至10万;而剩余的70万则仍未实现。因为此时lock修饰的是非静态方法(即实例级别的),所以每个线程均被分配了一个独立实例;因此这些线程的竞争并非基于同一把锁。若要使该代码运行正确,请将此行为归类为类级同步即可:只需将此方法声明为静态并使用synchronized关键字进行修饰即可实现类级互斥保护功能。

758b0573169e257ff3c10d5924e5a874.png

另外一种做法是定义一个静态变量,并且这种方法值得推荐。其原因在于将非静态的方法转换为静态的方法实际上相当于改变了代码的架构。

2a5038737c74dc0ab853b1d3cb527b1d.png

我们来总结一下,在使用synchronized关键字时需要明确的是它具体作用是什么?当用于修饰静态字段或方法时会产生类层面的同步机制;而用于修饰非静态字段或方法则会引发实例层面的同步操作

****

锁的粒度

普遍认为哈希表(Hashtable)不建议使用,而如果需要线程安全的数据结构,则应该选择ConcurrentHashMap。这是因为尽管哈希表(Hashtable)是线程安全的但它过于粗鲁它给所有方法都上了同一把锁!让我们深入查看源码。

88f1e356fec61afb57d6ad77a1f33fd6.png

你认为 contains 方法与 size 方法之间存在什么关联?当我在使用 contains 方法时为何无法同时调用 size 方法?这反映出锁设置过粗的问题亟需评估。采用不同类型的锁作为基础以提高并发处理能力的同时,在确保线程安全的前提下能够有效管理资源分配效率也是一个值得探讨的方向。然而仅仅依靠不同类型的 lock 机制还不足以完全解决多线程环境下的同步问题。特别是在一些不需要 lock 管理的具体业务逻辑中引入 lock 可能会导致资源浪费并影响整体性能表现。例如,在下面这段代码中

ab1912884426e15f2a6133f26bd7a125.png

第二段代码展示了正确的锁使用方法。然而,在常规业务代码中,并非像像我的代码中仅使用 sleep 这样直观易懂。此外,在某些情况下还需要调整执行顺序等手段以确保锁的有效粒度。另一方面,在其他情况下则需要确保锁具有足够的粗粒度。然而这部分内容由JVM自动检测并优化,请注意以下展示的例子。

d55237ab0752ab70bbbdc4746e24e027.png

能够观察到的是一个方法内部调用逻辑依次经历了加锁-执行A-解锁-加锁-执行B-解锁这一系列操作流程;实际上只需要经历加锁-执行A-执行B-解锁这三个主要步骤即可完成同样的功能。因此,在JVM进行即时编译时会对其实施锁的粗化处理(即粗化处理),将单个细粒度的物理锁扩展为一个包含多个操作单元的大范围虚拟锁;类似于这样的优化方式。

1471d1dfc43e636c64aace3a4071de45.png

JVM还会进行锁消除的操作。通过逃逸分析确定实例对象属于线程私有,则该对象必定是线程安全的。从而跳过对象中的加锁操作,并直接执行。

****

读写锁

读写lock是一种基于场景调整lock粒度的方法,在优化系统性能方面表现出色。它通过将一个普通lock拆分为read lock和write lock两部分,在处理大量数据时能够显著提升效率。具体来说,在read多于write的场景下应用效果尤为突出。例如我们可以自己构建的一个缓存系统就非常适合这种机制

ReentrantReadWriteLock

允许多个线程同时访问共享变量进行**.read操作的同时,在执行**.write操作时必须满足互斥条件(即write与write互斥、read与write互斥)。简单来说,在任何时刻只能有一个线程执行.write**操作的同时禁止其他所有线程进行read或write操作。我们来看一个小例子来进一步理解这一机制:这段代码模拟的是一个缓存系统的实现过程,在具体运行逻辑中包含了一些细节环节:首先程序会获取当前进程所需的资源(例如获取read锁),并从缓存中获取相关数据;如果发现当前缓存为空,则会立即释放当前进程所持有的资源(例如释放read锁);接着再去获取write锁并从数据库中取出新的数据更新到缓存中;最后将更新后的数据返回给调用方使用。

dbb6ffa45a31edbb8811b41efb550067.png

具体来说,这里的关键点在于再次判断data = getFromCache()是否存在值. 由于在同一时间点上可能会有多线程同时调用getData(),而缓存可能为空的状态导致所有线程都需要争夺写锁. 最后一个线程能够率先获取到write lock并将其数据存入缓存. 当一个线程获取write lock的时候发现缓存中已经有了数据,则无需再去数据库中进行查询操作. 当然,在使用Lock机制时需要遵循一定的规范,在完成操作后必须执行finally块以释放Lock.

26d65799a28940560e188708917fe035.png

但是写锁内可以再用读锁,来实现锁的降级 ,有些人可能会问了这写锁都加了还要什么读锁。 还是有点用处的,比如某个线程抢到了写锁,在写的动作要完毕的时候加上读锁,接着释放了写锁,此时它还持有读锁可以保证能马上使用写锁操作完的数据,而别的线程也因为此时写锁已经没了也能读数据 。 其实就是当前已经不需要写锁这种比较霸道的锁!所以来降个级让大家都能读。 小结一下,读写锁适用于读多写少的情况,无法升级,但是可以降级 。Lock 的锁需要配合 try- finally ,来保证一定会解锁。 对了,我再稍稍提一下读写锁的实现 ,熟悉 AQS 的同学可能都知道里面的 state ,读写锁就是将这个 int 类型的 state 分成了两半,高 16 位与低 16 位分别记录读锁和写锁的状态。它和普通的互斥锁的区别就在于要维护这两个状态和在等待队列处区别处理这两种锁 。 所以在不适用于读写锁的场景还不如直接用互斥锁 ,因为读写锁还需要对state进行位移判断等等操作。

StampedLock

这一项技术也简单提及一下,在1.8版本中被提出。其出镜率却稍逊于ReentrantReadWriteLock。它提供了write lock、pessimistic read lock以及optimistic read lock三种功能。其中write lock和pessimistic read lock与该机制中的read-writer互斥lock机制实质一致,并多了一个optimistic read特性。
换句话说,在执行read操作时是无法进行write操作的。
** stampsedlock 的乐观read功能允许一个线程进行write操作 **。
乐观read本质上等同于数据库中的 optimistic locking机制。例如通过version字段来进行版本控制。
举个例子:
select * from table where id in (select id from table where timestamp > :version and timestamp < :version + 1) with flashsort(1);

a1d3d6eafc516eba29fbfdc6920c87bc.png

StampedLock 乐观读就是与其类似,我们来看一下简单的用法。

4016fc4d271196a77d6c6f4c06eba2e7.png

相比之下,在实现高效并发方面ReentrantReadWriteLock表现更为出色。其他诸如StampedLock等机制则均不支持重入操作,并且同样无法提供条件变量的支持。特别值得注意的是,在使用StampedLock时必须避免调用中断操作,因为这将导致CPU资源利用率达到100%甚至更高水平的锁竞争状态出现。我进行了实验验证,在实际应用中成功复现了相关问题并得以妥善解决。

1e00ce2037952c623276241d3498808d.png

无需多言的具体原因无需再作详细阐述。文章末尾会附上相关链接,在此之前的部分内容已经非常详尽。因此出现了一个看似非常厉害的东西。真正掌握它并熟悉其本质是实现精准应用的关键。

****

CopyOnWrite

虽然进程 fork() 操作虽然常见于系统编程中但其特殊的线程隔离机制也广泛应用于业务代码层面以提高系统的吞吐量和稳定性。该机制的优势在于它能够在不影响原有运行的情况下为新线程提供独立的工作副本从而避免数据竞争问题这对于读操作频繁而写操作相对较少的情况特别适合使用这种机制能够有效提升系统的性能并降低潜在的同步冲突风险具体来说write-once-read-many 的设计模式使得每个修改动作都会触发一次数据副本的复制过程这对性能有一定影响但总体而言这种权衡是值得接受的特别是在高并发场景下能够保证系统的稳定运行

并发安全容器

在深入探讨并发安全容器的应用时,在我的工作经验中发现一种常见的误解是:人们认为只要采用了并行安全容器就能保证线程安全性。实际上并非如此——操作方式至关重要。为此我们接下来将详细分析如下所示的一段代码实现:在这一段代码中我们主要通过ConcurrentHashMap来管理员工薪资信息,并且限制最多有100条记录的具体情况。具体来说,在这段代码中我们主要通过ConcurrentHashMap来管理员工薪资信息,并且限制最多有100条记录的数量

4489e03eec48fc7df327abb98c390e0f.png

最终的结果都会有超标的情况发生,并且在map中记录的人数不仅超过了100个。那么如何才能使结果正确?正确的做法是加一把锁。

649a85f30a45c86b3e80cc75a490ddda.png

看到有人认为即使对HashMap进行了加锁操作还需要使用ConcurrentHashMap时,则表示即使对HashMap进行了加锁操作依然无法解决问题!事实上确实如此!因为在这种复合型操作的情景下,并不能保证ConcurrentHashMap提供的操作始终是线程安全的。因此,在这种复合型操作的情况下,并不能保证ConcurrentHashMap提供的操作始终是线程安全的。例如以下代码:

6dc54e3d9482bcec986df36308aaf0ac.png

但是,在我的示例中可能存在一定的偏差。实际上, ConcurrentHashMap相比HashMap加锁的原因在于分段锁机制,这在实际应用中可能需要一定数量的操作才能显现出来.值得注意的是,重要的是在使用时不要过于轻视这一点

****

总结一下

今天探讨了并发BUG的根源,并详细分析了其本质原因:可见性不足、操作不可atomically一致以及缺乏顺序保证等问题。随后重点介绍了synchronized关键字的应用要点:即用于修饰静态字段或方法的是类层面的锁(该类型的同步机制适用于整个对象集),而非静态字段和非静态方法则是实例层面使用的(该类型仅影响单个实例)。在此基础上进一步阐述了关于锁粒度的问题,在不同场景下定义不同的锁切记不能粗暴地采用一把通用锁;并且在方法内部实现时需要考虑细粒度操作(例如在读多写少的情况下可以灵活选择读写专用锁或基于复制操作的设计方案)。最终建议正确运用线程安全机制以确保复合操作的安全性

4b761ec1aec199d2fd2a1c57de1f045d.png

今天我只是简要地分享了一些观点。关于并发编程还有很多细节值得探讨。编写 thread-safe(线程安全)代码并非易事;正如与之前对 Kafka 事件处理流程的分析相似,在早期版本主要依赖锁机制来保证并发安全;结果发现这些问题难以修复。多线程编程确实存在诸多挑战;调试同样面临困难;而修复这些问题同样困难。因此 Kafka 事件处理模块最终改成了采用单线程事件队列模式 ,通过将涉及共享数据竞争相关访问的行为抽象为特定事件并将其放入阻塞队列中进行统一处理。所以请在考虑使用锁之前,请先评估其必要性和可行性。

有道无术,术可成;有术无道,止于术

欢迎大家关注Java之道 公众号

ef58b03df976121d1881c0128b56566a.png

好文章,我在看❤️

全部评论 (0)

还没有任何评论哟~