Java并发和多线程-synchronized、volatile、final关键字
synchronized
synchronized中文意思是同步,也称之为”同步锁“。
Synchronized关键字解决的是多个线程之间访问资源的同步性
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
synchronized的3种使用方式:
- 类锁
修饰静态方法:其作用的范围是整个静态方法,锁对象为Class对象
修饰代码块:synchronized指定锁对象为Class对象 - 对象锁
修饰实例方法:其作用的范围是整个方法,锁对象为this,当前实例对象
修饰代码块:其作用的范围是大括号{}括起来的代码,自己指定锁对象
实现原理
加锁和释放锁的原理:Monitor
JVM 是通过进入、退出对象监视器(Monitor)
来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的互斥锁(Mutex Lock)
实现。
具体实现是在编译之后在同步方法调用前加入一个monitor.enter
指令,在退出方法和异常处插入monitor.exit
的指令。
对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit
之后才能尝试继续获取锁。
可重入原理:加锁次数计数器
在同一锁程中,每个对象拥有一个monitor计数器,当线程获取该对象锁后,monitor计数器就会加一,释放锁后就会将monitor计数器减一,线程不需要再次获取同一把锁。
保证可见性的原理:内存模型和happens-before规则
Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁。
如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B。
JVM中锁的优化
简单来说在 JVM中 monitorenter 和 monitorexit 字节码依赖于底层的操作系统的 Mutex Lock 来实现的,但是由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。
然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。
在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
无锁 偏向锁 轻量级锁 重量级锁
自旋与自适应自旋
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态,同时在JDK 1.6中引入了自适应自旋锁。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁消除的主要判定依据来源于逃逸分析的数据支持。
意思就是:JVM会判断在一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。
锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。
1 | public static String test04(String s1, String s2, String s3) { |
在上述的连续append()操作中就属于这类情况。StringBuffer的每个方法都是synchronized修饰的。
JVM会检测到这样一连串的操作都是对同一个对象加锁,那么JVM会将加锁同步的范围扩展(粗化)到整个一系列操作的外部,使整个一连串的append()操作只需要加锁一次就可以了。
syncorinized与Lock
synchronized的缺陷
- 效率低
锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程;
相对而言,Lock可以中断和设置获取超时 - 不够灵活
加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),
相对而言,读写锁更加灵活 - 无法知道是否成功获得锁
相对而言,Lock可以拿到状态,如果成功获取锁,….,如果获取失败,…..Lock解决相应的问题
- lock(): 加锁
- unlock(): 解锁
- tryLock(): 尝试获取锁,返回一个boolean值
- tryLock(long,TimeUtil): 尝试获取锁,可以设置超时
Synchronized加锁只与一个条件(是否获取锁)相关联,不灵活,后来Condition与Lock的结合解决了这个问题。
多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断,高并发的情况下会导致性能下降。
ReentrantLock的lockInterruptibly()方法可以优先考虑响应中断。
一个线程等待时间过长,它可以中断自己,然后ReentrantLock响应这个中断,不再让这个线程继续等待。有了这个机制,使用ReentrantLock时就不会像synchronized那样产生死锁了。
syncorinized使用问题
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错
- synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。
volatile
volatile的实现原理
可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。
volatile 变量的内存可见性是基于**内存屏障(Memory Barrier)**实现
有序性
volatile 的 happens-before 关系
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
volatile 禁止重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
禁止上面的普通写和下面的 volatile 写重排序。 - 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。 - 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
禁止下面所有的普通读操作和上面的 volatile 读重排序。 - 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
禁止下面所有的普通写操作和上面的 volatile 读重排序。
volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。
volatile的应用场景
- 单例模式-双重检查 在单例模式中使用 volatile 主要是使用 volatile 的后一个特性(防止指令重排序),从而避免多线程执行的情况下,因为指令重排序而导致某些线程得到一个未被完全实例化的对象,从而导致程序执行出错的情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
syschronized(Singleton.class) {
if (instance == null) {
// 禁止此处可能发生的指令重排序
instance = new Singleton();
// 1、创建内存空间。
// 2、在内存空间中初始化对象 Singleton。
// 3、将内存地址赋值给 instance 对象(执行了这一步骤,instance 就不等于 null 了)。
// 试想一下,如果不加 volatile,那么线程 1 在执行到上述代码的第 ② 处时就可能会执行指令重排序,将原本是 1、2、3 的执行顺序,重排为 1、3、2。
// 但是特殊情况下,线程 1 在执行完第 3 步之后,如果来了线程 2 执行到上述代码的第 ① 处,判断 instance 对象已经不为 null,但此时线程 1 还未将对象实例化完,那么线程 2 将会得到一个被实例化“一半”的对象,从而导致程序执行出错,
// 这就是为什么要给私有变量添加 volatile 的原因了。
}
}
}
return instance;
}
}
final
- 所有的final修饰的字段都是编译期常量吗?
- 如何理解private所修饰的方法是隐式的final?
- 说说final类型的类如何拓展? 比如String是final类型,我们想写个MyString复用所有String中方法,同时增加一个新的toMyString()的方法,应该如何做?
- final方法可以被重载吗? 可以
- 父类的final方法能不能够被子类重写? 不可以
- 说说final域重排序规则?
- 说说final的原理?
- 使用 final 的限制条件和局限性?
基础使用
修饰类
当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的。
final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的。设计模式中最重要的两种关系,一种是继承/实现;另外一种是组合关系。所以当遇到不能用继承的(final修饰的类),应该考虑用组合。
修饰方法
private 方法是隐式的final,private方法无法被子类继承、覆盖。
final方法是可以被重载的。修饰参数
修饰变量
final域重排序规则
有考虑过final在多线程并发的情况吗?
在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是一弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。
那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗?
关于final重排序的总结
按照final修饰的数据类型分类:
- 基本数据类型:
- final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
- final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
- 引用数据类型:
- 额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量重排序