什么是线程安全

本文深入探讨了Java中的线程安全,从不可变对象到线程安全的五种分类,详细解释了绝对线程安全、相对线程安全、线程兼容和线程对立的概念。此外,还介绍了线程安全的实现方式,包括阻塞同步和非阻塞同步,以及JVM中的锁优化技术,如自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。最后,分析了自适应自旋和锁优化策略对性能的影响。
摘要由CSDN通过智能技术生成

其实在我们开发过程中,大多数Java程序都充斥着并发bug,它们仅仅是“碰巧”可以工作。
我们作为一个优秀的程序猿,怎么可以容忍不安全的情况存在呢,我们看下下面的内容来规避这些问题。

在这里插入图片描述

线程安全的强度

按照线程安全的“安全程度”由强到弱排序,我们可以将java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变

不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全的保证措施。只要一个不可变的对象被正确地构建出来(没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、最纯粹的。
我们可以通过final关键字进行修饰,例如java.lang.String类的对象实例,就是一个典型的不可变对象,用户不管是调用substring()、replace()还是concat()这些方法都不会影响它原来的值,只会返回一个新的字符串对象。

绝对线程安全

这个定义其实来说是非常严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能要付出昂贵的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全的。
我们可以看下java.util.Vector,这个容器的定义就是线程安全的,其add()、get()、和size()等方法都是被synchronized修饰的,这样虽然损耗了一些效率但是保证了原子性。但是在多线程的环境下,如果不在调用端做额外的同步措施,当一个线程恰好在错误的时间里删除了一个元素,导致序列号i已经被删了,这样再用i访问数组就会抛出ArrayIndexOutOfBoundsException异常。假设Vector一定要做到绝对的线程安全,那就必须在它的内部维护一组一致性的快照才行,每次对其元素进行修改都要产生新的快照。

相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,我们在调用的时候不需要进行额外的保障措施,但是对一些有特定顺序的连续调用,可能就需要使用额外的手段来保证调用的正确性了。Java中,大部分声称线程安全的类都属于这种类型,比如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全的使用。我们平常说的一个类并不是线程安全的通常就是指这种,Java类库API中大部分的类都是线程兼容的。

线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java语言天生就支持多线程的特性,线程对立这种代码出现的很少。
比如Thread类的suspend()和resume()方法。如果两个线程同时持有一个线程对象,一个尝试中断线程,一个尝试恢复线程,在并发的情况下,无论调用的时候是否进行了同步,目标都存在死锁的风险,所以现在suspend()和resume()方法都已经被声明废弃了。

线程安全的实现方式

阻塞同步

这个是我们最常用的保障手段。同步是指在多个线程并发访问共享数据的时候,保证共享数据在同一个时刻只能被一条线程使用。
例如synchronized关键字实现的就是阻塞同步,被synchronized修饰的同步块对同一条线程来说是可重入的,并且其在释放锁之前会无条件地阻塞后面的线程。这个就意味找无法像处理某些数据库中的锁那样,强制已获取的线程释放锁;也无法强制正在等待的线程中断等待或者超时退出。
JDK5以后引入了java.util.concurrent.locks.Lock来实现互斥同步,其中ReentrantLock是Lock接口最常见的一种实现。

非阻塞同步

互斥面临的主要问题是进行线程阻塞和唤醒带来的性能开销,因为也称为阻塞同步。

在JDK 5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的
compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程,或者可以认为是无条件内联进去了。

在JDK 9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。而如果用户程序也有使用CAS操作的需求,那要么就采用反射手段突破Unsafe的访问限制,要么就只能通过Java类库API来间接使用它。直到JDK 9之后,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。

CAS虽然看起来既简单又高效,但是这种操作无法涵盖互斥同步的所有场景,并且从CAS存在一个逻辑漏洞,如果一个变量A初次读取的时候为1,并且准备给其赋值的时候检查到它的值任然为1,但是并不能保证中间过程没有被其他线程改过,如果别的线程在这段时间把其改为2后又改成1,那么CAS操作会任务它没有被改变过。不过JUC包后来为了解决这个问题引入了一个原子引用类AtomicStampedReference,它可以通过变量值的版本来保证CAS的正确性,不过大部分ABA问题并不会影响程序的正确性,如果需要解决ABA问题,改用互斥同步可能会比原子类更高效。
下面我们通过代码来看Atomic的原子自增运算
在这里插入图片描述

最终输出的结果为2000,所以使用了AtomicInteger后,程线输出了正确的结果,这个要归功于incrementAndGet()方法的原子性,我来看下相关的源码
在这里插入图片描述

可以看到这个incrementAndGet里,使用了一个unsafe类,unsafe里提供了一个getAndAddInt方法,我们进一步看下其源码
在这里插入图片描述

我们可以看下其具体的实现,其核心是调用了一个compareAndSwapInt
在这里插入图片描述

compareAndSwapInt是一个native标注的方法,这个代表是底层的代码。
所以我们回头看getAndAddInt的实现,当执行compareAndSwapInt失败的时候是一直执行,直到成功为止。

无同步方案

要保证线程安全,也并非一定要进行阻塞或者非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此一些代码天生就是线程安全的。
可重入代码:这种代码又称纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误,也不会对结果产生任何影响。我们可以认为可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。

锁优化

JDK6有一个重要的改进,就是各种锁的优化,如适应性自旋(Adaptive Spinning)、锁消除(Lock
Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased
Locking)等,这些技术都是为了在线程之间更高效地共享数据及解决竞争问题,从而提高程序的执行效率。

自旋锁与自适应自旋

前面我们说到的互斥同步的问题,互斥同步是阻塞的,挂起线程和恢复线程的操作都要转入内核态中完成,这些给java虚拟机的并发性能带来了很大的压力。有时候共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。所以我们可以让后面的线程稍等一会,就是让线程执行一个忙循环(自旋)。
自旋锁在JDK1.4.2中就已经引入了,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK6中已经改成默认开启了。自旋等待不能代替阻塞,虽然自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器的时间的,如果锁被占用的时间很短,自旋等待的效果会很好。但是如果锁被占用的时间很长,那么自旋只会白白浪费资源,因此自旋等待的时间必须要有一定的限度。自旋次数的默认值是十次,用户也可以通过使用参数-XX:PreBlockSpin来自行更改,当超过次数后就会挂起线程。
但是针对不同的场景可能自旋的次数要求是有差异的,不能说某一个值对所有场景都适用,所以在JDK6中对自旋锁也做了优化,引入了自适应自旋。自适应意味找自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。有了自适应自旋,随着程序运行时间的增长以及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准。

锁消除

锁消除是指虚拟机即使编译器在运行中,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问,那么就可以把他们当成栈上的数据对待,认为它们是线程安全的,就无须加锁了。

锁粗化

原则上,我们在编写代码的时候,推荐将同步块的作用范围限制尽量是越小越好,这样使得同步的操作数量尽可能的变少,即使存在锁竞争,等待的时间也会变少。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作可能是在循环体中,那即使是没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。这种情况下虚拟机就会将锁进行粗化。

轻量级锁

JDK6时引入的新型锁机制,轻量级锁能提升程序性能的依据是“绝大多数的锁,在整个同步周期能是不存在竞争的”的经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销。但是如果存在锁竞争关系,CAS除了互斥量的本身开销外,还有额外的一些CAS操作的开销。因此在有竞争的情况下,使用轻量级锁反而会比传统的重量级锁效率更低。

偏向锁

也是JDK6中引入的一项锁优化措施,它的目的是消除在无竞争情况下的同步原语。偏向锁的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程将永远不需要进行同步。
我们可以通过参数-XX:+UseBiased Locking(默认值)开启,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志设置为“01”,把偏向模式设置为“1”,表示进入偏向模式,同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块的时候,虚拟机都可以不再进行加锁、解锁、以及对Mark Word的更新操作等。
但是一旦出现另外一个线程尝试获取这个锁,偏向模式就马上结束,并且锁会膨胀为重量级锁。偏向锁可以提高带有同步但是没有竞争的程序的性能,但是如果在大多数的锁都是被多个不同的线程访问的情况下,就可以通过参数-XX: -UseBiasedLocking来禁止偏向锁优化来提高性能。

参考文献:
《深入理解Java虚拟机》

感谢各位大佬的❤️关注+点赞❤️,原创不易,鼓励笔者创作更好的文章

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值