Synchronized
在多线程并发中synchronized一直是元老级别的角色。利用synchronized来实现同步具体有一下三种表现形式:
- 对于普通的同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的class对象。
- 对于同步方法块,锁是synchronized括号里配置的对象。
当一个代码,方法或者类被synchronized修饰以后。当一个线程试图访问同步代码块的时候,它首先必须得到锁,退出或抛出异常的时候必须释放锁。那么这样做有什么好处呢?
它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量的可见性和排他性。
如何实现排他性
如下图所示,一个普通的方法会有一个左右摆动的开关,可以连接到任意一个线程,如果该方法代码不是原子性的,可能会出现一个线程并没有将方法代码执行完毕就链接到另一个线程中去。而被synchronized修饰的方法,链接到一个线程后,除非这个线程将方法执行完毕或者抛出异常,开关才会链接至别的线程。就这样将一个并行的操作变了穿行操作。(同一时间保证只有一个线程在执行方法代码)
int i = 1;
public synchronized void increment(){
i++;
}
在前面并发基础及锁的原理中我们介绍过i++并不是原子操作,所有当多个线程同时操作i++的时候可能会出现多线程并发问题。而上诉代码块中i++是在synchronized修饰的方法中。其中一个线程进入该方法首先获得当前实例对象的锁,当另一个线程试图执行该方法的时候,由于前一个线程并没有执行完毕释放掉锁,所以该线程挂起等待锁的释放。
通过加锁的方式我们实现了将i++非原子操作的方法变成了原子操作的方法。从而实现了排他性。
如何实现可见性
因为在java内存模型中规定:在执行被synchronized修饰的代码时,线程首先获取锁→清空工作内存→在主内存中拷贝最新变量的副本到工作内存→执行完代码→将工作内存中更改后的共享变量的值刷新到主内存中→释放互斥锁。
这里有一个细节需要注意: 当一个线程A将最新的共享变量刷新到主内存的时候,会导致缓存在其他线程B的工作内存的这个共享变量失效。
当线程B下一次去访问这个变量的时候,会发现,工作缓存的这个变量已经失效。会强制从主内存中重新读取这个共享变量
Volatile
当声明共享变量为volatile后,对这个变量的读/写将会很特别。volatile可以说是java虚拟机提供的最轻量级的同步机制。他只能能只能保证变量的可见性与读/写的原子性。要理解volatile确实是不容易的,接下来我们进入深入的分析!
volatile的特性
下面有两个示例代码:
public class VolatileTest1 {
volatile long a = 0L; //使用volatile声明64位的long型变量
public void set(long b) {
a = b; //单个volatile变量的写
}
public void increment() {
a++; //复合(多个)volatile变量的读/写
}
public long get() {
return a; //的那个volatile变量的读
}
}
public class VolatileTest2 {
long a = 0L; //64位的普通long型变量
public synchronized void set(long b) { //单个普通变量的写使用同步锁
a = b;
}
public void increment() { //普通方法调用
long tmp = get(); //调用以同步的读方法
tmp += 1; //普通的写操作
set(tmp); //调用以同步的写方法
}
public synchronized long get() { //单个普通变量的读使用同步锁
return a;
}
}
上述两个示例代码所带来的的执行效果是相同的。
可以看到被volatile修饰的变量读与写操作是原子性的。如前面所述,被Synchronized修饰的变量每次写操作完成后,会强制将工作内存中缓存的共享变量强制刷新到主内存中。所以保证了volatile修饰变量的可见性。
从上述示例代码中我们也能看出,即便读与写是原子性,但是依旧不能保证 a++;是原子操作。这也是很多人对volatile字段理解困难的原因所在。
简而言之,volatile变量自身具有下列特征。
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volat变量的读 / 写具有原子性,但类似volatile++这种复合操作不具有原子性。
这里插入一个以前楼主遇到过的一个面试题:请问对于double和long类型的读写是原子性的吗?
double和long类型是64位的,在一些32位的处理器上,可能会把一个64位的long/double型变量的写操作才分为两个32位的写操作来执行。所以此时对这个64位变量的写操作将不再具有原子性。 但是如果被volatile修饰的话,写64位的double和long的操作依旧是原子操作。
volatile的禁止重排序
除了前面内存可见性中讲到的volatile关键字可以保证变量修改的可见性之外,还有另一个重要的作用:在JDK1.5之后,可以使用volatile变量禁止指令重排序。
volatile关键字通过提供“内存屏障”的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障
总结来说:
- volatile写操作之前的操作不会被编译器重排序到写操作之后。
- volatile读之后的操作不会被编译器重排序到volatile读操作之前。
- 第一个是volatile读操作,第二个是volatile写操作,不能重排序
volatile的使用场景
-
状态标志
用volatile修饰的boolean 变量来作为while循环的的判断条件:当这个变量被其他线程修改的时候能保证while循环能立即读到。
-
一次性安全发布
初始化对象的正确步骤为:
1、分配对象的内存空间
2、初始化对象
3、设置引用指向刚分配的内存地址
然而由于重排序机制,可能导致2、3步骤重排序,导致初始化对象的步骤变为 1-3-2。
著名的**双重检查锁**定存在的问题就是因为初始化对象的重排序,引用所指向的对象可能还没有完成初始化,而仅仅是指向了一个空的内存地址。 -
独立观察
这是第一种使用场景的引用。例如一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
-
开销较低的读-写锁策略
前面我们介绍过,因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。但是被volatile修饰变量的读 / 写却是原子操作。所以当共享变量被volatile修饰之后,我们只需要在复合操作的方法上加上synchronized比直接用synchronized修饰该变量效率高的多。
volatile总结
相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。
volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatilei变量是一种比synchronized关键字更轻量级的同步机制。
synchronized和volatile的区别
1、 volatile不会进行加锁操作:
volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
2、volatile变量作用类似于同步变量读写操作:
从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
3、volatile不如synchronized安全:
在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
4、volatile无法同时保证内存可见性和原则性:
加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。