Java并发-线程安全的机制
本文最后更新于:2 年前
synchronized
解决多个线程访问资源的同步性。可以保证被他修饰的方法或者代码块在任意时刻只有一个线程执行。
原理
synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指明同步代码块的结束位置。
当执⾏ monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor 实现的。每个对象中都内置了⼀个 ObjectMonitor 对象。
另外, wait/notify 等⽅法也依赖于 monitor 对象,这就是为什么只有在同步的块或者⽅法中才能调⽤ wait/notify 等⽅法,否则会抛出 java.lang.IllegalMonitorStateException 的异常的原因。
在执⾏ monitorenter 时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。
在执⾏ monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。
scACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法。JVM 通过该访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。
两者的本质都是对对象监视器 monitor 的获取 。
sychronized 改进
java1.6 以后,引入了偏向锁和轻量级锁。
锁有四种状态:无锁,偏向锁,轻量级锁,重量级锁,可以升级,不能降级。
可重入性
通过记录锁的持有线程和持有数量实现。
内存可见性
释放锁时所有的写入操作都会写回内存。获得锁后,都会从内存里读取数据。
如果只是保证内存可见性,volatile 即可(加上后 java 会在操作对应变量时插入特殊指令)
死锁
避免在持有一个锁时申请另一个锁。
jstack 报告死锁
使用方法:
修饰实例方法。锁的是实例对象(this)。
修饰静态方法。锁的是 Class 类对象。
修饰代码块。指定加锁对象。
如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁 。
显示锁
接口:lock,实现类:reentrantlock
接口:readwritelock,实现类:reentrantreadwritelock
显示锁支持非阻塞的方式获取锁,可以响应中断,限时,因此更加灵活。
使用 trylock 可以避免死锁
实现原理
依赖于 cas,juc 下的 locksupport
主要方法:park,unpark
对比 sychronized 和 reentrantlock
volatile
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现【比较并交换】的效果
为什么无锁效率高
无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。(多核)
原子变量以及 CAS
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
cas+volatile 可以实现原子变量
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,再重试。
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
CAS 体现的是无锁并发、无阻塞并发
因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响
ABA 问题
CAS 更新:a-b-a,当前线程的 CAS 操作无法分辨。
解决:时间戳
写时复制
threadlocal
threadlocal 而是一个线程内部的存储类,可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据。
ThreadLocal 的作⽤主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程⽽⾔是相对隔离的,在多线程环境下,如何防⽌⾃⼰的变量被其它线程篡改。
对于某一 ThreadLocal 来讲,他的索引值 i 是确定的,在不同线程之间访问时访问的是不同的 table 数组的同一位置即都为 table[i],只不过这个不同线程之间的 table 是独立的。
对于同一线程的不同 ThreadLocal来讲,这些 ThreadLocal 实例共享一个 table 数组,然后每个 ThreadLocal 实例在 table 中的索引 i 是不同的。
ThreadLocal 和 Synchronized 都是为了解决多线程中相同变量的访问冲突问题,不同的点是:
Synchronized 是通过线程等待,牺牲时间来解决访问冲突
ThreadLocal 是通过每个线程单独一份存储空间,牺牲空间来解决冲突,并且相比于 Synchronized,ThreadLocal 具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问到想要的值。
ThreadLocalMap 底层结构?
未实现 Map 接⼝,⽽且他的 Entry 是继承 WeakReference(弱引⽤)的,也没有 HashMap 中的 next(无链表)
为什么是数组结构?
⼀个线程可以有多个 TreadLocal来存放不同类型的对象的,但是他们都将放到你当前线程的 ThreadLocalMap⾥,所以肯定要数组。
解决 hash 冲突?
在 get 的时候,也会根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置,然后判断该位置 Entry 对象中的 key 是否和 get 的 key⼀致,如果不⼀致,就判断下⼀个位置,set 和 get 如果冲突严重的话,效率还是很低的。
对象存放在哪⾥么?
在 Java 中,栈内存归属于单个线程,每个线程都会有⼀个栈内存,其存储的变量只能在其所属线程中可
⻅,即栈内存可以理解成线程的私有内存,⽽堆内存中的对象对所有线程可⻅,堆内存中的对象可以被
所有线程访问。
ThreadLocal 的实例以及其值存放在栈上呢?
其实不是的,因为 ThreadLocal 实例实际上也是被其创建的类持有(更顶端应该是被线程持有),⽽
ThreadLocal 的值其实也是被线程实例持有,它们都是位于堆上,只是通过⼀些技巧将可⻅性修改成了
线程可⻅。
共享线程的 ThreadLocal 数据怎么办?
使⽤ InheritableThreadLocal 可以实现多个线程访问 ThreadLocal 的值,我们在主线程中创建⼀个 InheritableThreadLocal 的实例,然后在⼦线程中得到这个 InheritableThreadLocal 实例设置的值。
如果线程的 inheritThreadLocals 变量不为空,⽽且⽗线程的 inheritThreadLocals 也存在,那么就把⽗线程的 inheritThreadLocals 给当前线程的 inheritThreadLocals 。
问题?
只具有弱引⽤的对象拥有更短暂的⽣命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程,因此不⼀定会很快发现那些只具有弱引⽤的对象。
这就导致了⼀个问题,ThreadLocal 在没有外部强引⽤时,发⽣GC 时会被回收,如果创建 ThreadLocal 的线程⼀直持续运⾏,那么这个 Entry 对象中的 value 就有可能⼀直得不到回收,发⽣内存泄露。
就⽐如线程池⾥⾯的线程,线程都是复⽤的,那么之前的线程实例处理完之后,出于复⽤的⽬的线程依然存活,所以,ThreadLocal 设定的 value 值被持有,导致内存泄露。
解决:⼀个线程使⽤完,ThreadLocalMap 是应该要被清空的,手动调用 remove()方法。