面试常考-synchronized与volatile
面试一问到java并发, 除了考查JMM之外,最多的应该就是考察synchronized和volatile关键字了。
synchronized
synchronized是什么:
Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。
当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。
然而,当一个线程访问object的一个加锁代码块时,另一个线程仍可以访问该object中的非加锁代码块。
synchronized用法:
修饰代码块:
指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
注意这个加锁对象:
同步对象为普通对象
如果加锁对象为普通对象实例, 那么作用的只是这个实例, 而不是这一类。
比如:
class Sync extends Thread {
private Object obj;
public Sync(Object obj) {
this.obj = obj;
}
@Override
public void run() {
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "开始执行同步...");
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "执行同步结束");
}
}
}
比如当我们作用的这个obj实例为不同时,对于多线程就不会出现竞争。
测试:
public static void main(String[] args) {
Sync[] syncs = new Sync[4];
for (int i = 0; i < 4; i++) {
// 同一类但是是不同的实例对象
syncs[i] = new Sync(new Object());
syncs[i].start();
}
}
输出结果:
Thread-2开始执行同步...
Thread-0开始执行同步...
Thread-3开始执行同步...
Thread-1开始执行同步...
Thread-0执行同步结束
Thread-2执行同步结束
Thread-3执行同步结束
Thread-1执行同步结束
另一种情况: 作用的只是一个实例
Sync[] syncs = new Sync[4];
Object obj = new Object();
for (int i = 0; i < 4; i++) {
// 同一实例obj
syncs[i] = new Sync(obj);
syncs[i].start();
}
输出结果:
Thread-0开始执行同步...
Thread-0执行同步结束
Thread-3开始执行同步...
Thread-3执行同步结束
Thread-2开始执行同步...
Thread-2执行同步结束
Thread-1开始执行同步...
Thread-1执行同步结束
同步对象为类
当代码块加锁对象为类, 锁住的就是这个class类时:
class Sync extends Thread {
@Override
public void run() {
// 加锁对象为类
synchronized (Object.class) {
System.out.println(Thread.currentThread().getName() + "开始执行同步...");
try {
Thread.sleep(1000);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "执行同步结束");
}
}
}
当多个线程执行时:
public static void main(String[] args) {
Sync[] syncs = new Sync[4];
for (int i = 0; i < 4; i++) {
syncs[i] = new Sync();
syncs[i].start();
}
}
输出结果:
Thread-1开始执行同步...
Thread-1执行同步结束
Thread-3开始执行同步...
Thread-3执行同步结束
Thread-2开始执行同步...
Thread-2执行同步结束
Thread-0开始执行同步...
Thread-0执行同步结束
为什么锁的类就会保证同步?
这是因为jvm内存中有一个叫class文件常量池的东西,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中。 而对于每一个class类都只会保存一份, 所以当唯一的class类被锁住时,其他线程只能等待持有锁的线程释放才可以继续进入同步代码块。
所以注意区分加锁对象。
synchronized修饰方法在本质上和修饰代码块是一样的,他们都是通过同步锁来实现同步的。
修饰普通方法:
synchronized 修饰普通方法中的同步锁就是这个对象本身,即"this"。
作用于当前对象实例,进入同步代码前需要获得当前对象的锁。
所以就很容易理解了, 既然是this 说明锁的肯定是实例对象,而不是类文件,那么对于同一类的不同实例对象;
修饰静态方法:
同样的,synchronized作用于静态方法时,跟使用类对象作为静态锁的效果是一样的,此时的类对象就是静态方法所属的类。
进入同步代码前要获得当前类对象的锁,synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁, 而class类在常量池中只会有一份。
synchronized作用
知道了其使用方法之后,我们在使用时它就是确保对同步代码块的互斥访问。
所以其作用就有
- 原子性: 确保线程互斥的访问同步代码;
除此之外还有 - 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
- 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”。
synchronized底层原理
我们知道synchronized使用方法主要有作用于同步块和方法两种类型:
- synchronized 同步代码块
其 实现是通过 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权。
其内部包含一个计数器,当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止
- synchronized同步方法
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
synchronized优化
在说优化之前先了解下下对象头中markword的结构:
以Hotspot为例:
当对象处于不同锁对象时,其markword的结构也不尽相同,如上所示:
当处于
- 无锁态: 偏向锁位0, 锁标志位01
- 偏向锁: 偏向锁位1, 锁标志01
- 轻量级锁: 锁标志位00
- 重量级锁: 锁标志10
- GC标记信息, 锁标志11
对于前四种状态,也就是synchronized优化中的一种: 锁膨胀
1 锁膨胀
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁 (膨胀方向不可逆)
偏向锁
为了减少同一线程获取锁的代价; 大多数情况下,锁不存在多线程竞争,总是会由同一线程多次获得。
**核心思想:**如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 Mark Word 的锁标记位为偏向锁以及当前线程ID等于 Mark Word 的ThreadID即可,这样就省去了大量有关锁申请的操作。
轻量级锁
轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。
多看看流程图,便于了解是如何获取的。
重量级锁
重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
重量级锁获取流程:
2 锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,**对运行上下文进行扫描,去除不可能存在竞争的锁。**比如下面代码的method1和method2的执行效率是一样的,因为object锁是私有变量,不存在所得竞争关系。
public class test {
public void method1() {
Object obj = new Object();
synchronized (obj) {
// 同步块
// do something
}
}
public void method2() {
Object obj = new Object();
// do something
}
}
3 锁粗化
锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。比如下面method3经过锁粗化优化之后就和method4执行效率一样了。
public class test {
public void method3() {
for (int i = 0; i < 100000000; i++) {
synchronized (Object.class) {
// 同步块
// do something
}
}
}
public void method4() {
synchronized (Object.class) {
for (int i = 0; i < 100000000; i++) {
// do something
}
}
}
}
4 自旋锁与自适应自旋锁
通常情况下,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。减少cpu切换。
但是在许多场景下,同步资源锁定的时间很短, 为了这一小段时间去切换线程的开销远远大于等待开销。如果是多处理机可以让两个或以上的线程同时执行, 那么就可以让请求同步锁的线程不放弃CPU而等待看看持有锁的线程是否很快会释放锁。
所以为了减少这种切换,让当前线程 “原地踏步等一等” ,也就是通过自旋,如果在自旋期间持有锁的线程释放了锁,那么当前线程就可以不必等待而直接获取同步资源, 这就是自旋锁的由来。
但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。
自适应自旋锁: 这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
volatile
volatile是什么
volatile是Java虚拟机提供的轻量级的同步机制,是基本上遵守了JMM的规范,主要是保证可见性和禁止指令重排,但是它并不保证原子性。
其主要的作用包括两点:
- 保证线程间变量的可见性。
- 禁止CPU进行指令重排序。
可见性
这个可见性指的是当有线程对这个volatile修饰的变量修改过时, 其他线程是立即得知的。而普通变量做不到这一点。
这是因为每个线程都有自己的工作内存, 线程的操作都是在工作内存中进行操作的,所以修改的普通变量新值不会刷新到主内存中,其他线程也不可见。 而volatile修饰的变量会重新存回主内存,并使得其他线程的工作内存中的该变量失效,而去主内存重新读取。
不保证线程安全
volatile虽然可以保证变量可见性, 但不能保证线程安全,因为它不能保证操作的原子性。
以下面代码为例:
count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。
public class test2 {
static volatile int count = 0;
public static void add() {
count++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
add();
}
}
});
threads[i].start();
}
// 保证上述十个线程都执行完毕
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(count);
}
}
输出 9707 多次尝试发现基本不会出现正确结果10 * 1000 = 10000;
为了保证线程安全,需要使用synchronized关键字或者lock锁,给count++这段代码上锁:
public static synchronized void add() {
count++;
}
输出结果:
禁止指令重排序
as-if-serial语义: 不管指令如何重排,只要最终程序的执行结果不会改变
所以,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作顺序与程序代码中的执行顺序一致,在同一线程方法执行中是无法感知到这一点的(引用来自《深入理解java虚拟机》)
这就叫指令重排序。
结合类的生命周期:
类加载过程如下:
加载,加载分为三步: 1、通过类的全限定性类名获取该类的二进制流; 2、将该二进制流的静态存储结构转为方法区的运行时数据结构; 3、在堆中为该类生成一个class对象;
- 验证:验证该class文件中的字节流信息复合虚拟机的要求,不会威胁到jvm的安全;
- 准备:为class对象的静态变量分配内存,初始化其初始值;
- 解析:该阶段主要完成符号引用转化成直接引用;
- 初始化:到了初始化阶段,才开始执行类中定义的java代码;初始化阶段是调用类构造器的过程;
(注意下准备阶段中的初始值是jvm中变量的默认初始值,而不是程序员定义的初始值)。
而有时候并不是按照这个顺序执行的加载,解析和初始化两阶段可能调换顺序,此时在多线程环境下就可能让一个未初始化的对象引用暴露出来,从而导致不可预料的结果。
以DCL单例举例:
class Singleton {
public static Singleton singleton;
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
因为有指令重排序的存在,双锁检测也不一定是线程安全的。
因为singleton= new Singleton(); 初始化对象的过程其实并不是一个原子的操作,它分为5个步骤
- 为对象分配内存(为对象分配内存的任务实际上便等同于把一块确定大小的内存从java堆中划分出来)
- 内存分配完成之后必须将分配到的内存空间都初始化为零值Null(这部操作保证了对象的实例字段在java代码中可以不赋初值就直接使用)
- 对对象进行必要设置(例如这个对象时那个类的实例,对象的GC分代年龄等)
- 执行new指令,调用构造方法对对象初始化
- 将 singleton对象指向分配的内存空间
当出现前面描述的指令重排,也就是解析与初始化顺序改变,就会出现错误情况如: 如果A线程率先进入同步代码块并先执行了 5 而没有执行 4,此时因为 instance 已经非 null。这时候线程 B 在第一次检查的时候,会发现 instance 已经是 非null 了,就将其返回使用,但是此时 instance 实际上还未初始化,自然就会出错。
所以在多线程环境下,就需要禁止指令重排序。
volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
禁止指令重排的原理:
volatile是通过内存屏障的方式来解决指令重排,通过获取JIT(即时Java编译器,把字节码解释为机器语言发送给处理器)的汇编代码,发现volatile多加了lock addl指令,这个操作相当于一个内存屏障,使得lock指令后的指令不能重排序到内存屏障前的位置。这也是为什么JDK1.5以后可以使用双锁检测实现单例模式。
总结
一提到JUC, 往往就是围绕原子性,可见性,有序性展开;而synchronized关键字通过只允许一个线程进入同步块来保证同步块的三种性质;而volatile虽然说不可以保证操作的原子性,但是可以保证可见性和有序性,并且设计到底层的内存屏障机制。知识多多, 难点多多。这笔记写完感觉要学的更多了。