普通Demo
public class Test {
public static void main(String[] args) {
Autumn a=new Autumn();
a.start();
for(;;){
if (a.isFlag()){
System.out.println("我是Autumn");
}
}
}
}
class Autumn extends Thread{
private boolean flag=false;
public boolean isFlag(){
return flag;
}
@Override
public void run(){
try {
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
finally {
flag=true;
System.out.println("flag:"+flag);
}
}
}
上述代码的并不能输出“我是Autumn”,但是为什么呢?我们明明已经在线程里将flag修改成了True,为什么无法打印?这就是涉及到了多线程中很经典的一种情况——可见性。
JMM——Java内存模型
JMM规范
所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
线程对变量的所有操作(读/写)都必须在工作内存中进行,不能直接读写主存中的变量
不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
工作内存和主存的关系
正是因为上述的模型,才会导致可见性问题。
如何解决可见性——synchronized和volatile
1.用synchronized加锁
public class Test {
public static void main(String[] args) throws InterruptedException {
Autumn a=new Autumn();
a.start();
for(;;){
synchronized (a){
if (a.isFlag()){
System.out.println("我是Autumn");
}
}
}
}
}
如上述代码所示,因为某一个线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。
所以使用加锁的方式就相当于每次执行操作前都会先清空自己工作内存中的变量值,然后再去主存中读取最新的变量值。
而获取锁失败的线程会被阻塞,这样就保证当前获取的变量值是最新的。
2.volatile修饰变量
class Autumn extends Thread{
private volatile boolean flag=false;
public boolean isFlag(){
return flag;
}
@Override
public void run(){
try {
Thread.sleep(1000);
}catch (Exception e){
e.printStackTrace();
}
finally {
flag=true;
System.out.println("flag:"+flag);
}
}
}
但是加锁不可避免的会涉及到线程切换,会增加系统开销,那么此时我们可以使用volatile来修饰变量,如上图代码所示,用volatile修饰flag变量。
volatile的作用
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果他操作了数据并且写会了,他其他已经读取的线程的变量副本就会失效了,需要都数据进行操作又要再次去主内存中读取了。
volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。
Tips:一句话来说,volatile修饰的变量是在主存上进行读写操作,所以可以保证可见性
但是这样也会存在一个问题,如果有多个CPU的运算任务都涉及到同一块主内存区域的时候,比如说变量在多个CPU之间的共享,我们该以哪个结果为标准呢?——这就涉及到了缓存一致性的问题。
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等
Intel的MESI协议
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
如何发现自己存储的变量是无效的?
1.嗅探
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
存在的缺点(总线风暴):由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。
volatile和synchronized的区别
volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。
volatile保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而synchronized是一种排他(互斥)的机制。
volatile用于禁止指令重排序:可以解决单例双重检查对象初始化代码执行乱序问题。
volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。
volatile是无锁操作,所以性能比synchronized更好
volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其他线程后续对v的读操作。
synchronized和Lock
- synchronized是关键字,属于JVM层面,而Lock是一个接口,属于JDK层面
- synchronized会主动释放锁,但是Lock必须手动释放
- synchronized具有原子性,所以是不可中断的,而Lock可中断也可以不中断
- synchronized可以锁住代码块和方法,而Lock只能锁住方法
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
应用场景
比如我现在是滴滴,我早上有打车高峰,我代码使用了大量的synchronized,有什么问题?锁升级过程是不可逆的,过了高峰我们还是重量级的锁,那效率是不是大打折扣了?这个时候你用Lock就更好。
Synchronized升级机制
我们以前经常会听到一个说法——synchronized是重量级锁,但是近来却少有人再提起
具体的原因就是因为Java6引入的锁升级机制和锁优化技术,提高了锁的效率。
升级过程
Tips:无锁就是CAS,就不再赘述了
偏向锁
之前的文章提到过,对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。
这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,CAS会失败,也就意味着获取锁失败。
偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是
xx:-UseBiasedLocking=false。
那么有多个线程竞争锁的时候,我们该怎么解决呢?——轻量级锁应运而生
轻量级锁
还是跟Mark Work 相关,如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝,然后把Lock Record中的owner指向当前对象。
JVM接下来会利用CAS尝试把对象原本的Mark Word 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。
如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。
重量级锁
那么升级为轻量级锁之后,允许多个线程竞争一个锁了,此时线程使用忙等待的方式(CPU自旋)来尝试获取锁,虽然忙等待的性能损耗比线程切换小,但是长时间的忙等待也会导致CPU的性能损耗过大,因为一次自旋就相当于一次CPU空转,那么默认的自旋次数是10次,当一个线程自旋次数超过10次,轻量级锁升级为重量级锁,也就是我们说的synchronized了。
References
敖丙——JavaFamily