Java多线程篇--并发关键字synchronized和volatile

在之前的文章中,已经发布了常见的面试题,这里我花了点时间整理了一下对应的解答,由于个人能力有限,不一定完全到位,如果有解答的不合理的地方还请指点,在此谢过。

本文主要描述的是java多线程中面试的锁机制。这个是面试中的重点内容,也是平时开发过程中经常碰到的问题,所以我们一定要重点了解锁的内容。锁的内容主要有关键字volatile,synchronized,这两个要掌握其实现的原理,下面的内容会重点描述这两个关键字的实现原理以及面试中可能出现的问题。如果对这些感兴趣的话,可以看下公众号中源码的内容。

 

了解volatile关键字么?作用是什么?能否保证线程安全?

volatile是java并发的基石,其两个作用是有序性和可见性的保证。但是不能保证原子性,也不能保证线程安全volatile在实现可见性上主要是通过lock指令实现。如果我们对一个变量加上volatile关键字,那么在编译成汇编的时候会加上lock前缀,该指令的作用是:

  1. 将当前处理器的缓存行数据写回到系统内存
  2. 这个写回内存的操作会使其他cpu里面缓存的了该内存地址的变为无效,

这两个和我们之前讲的内存模型里面是一致的,就是实现写回主存,其他内存地址无效,那么其他地址如果要读取数据的话,就必须要从主存中从新拉取数据。通过该指令实现可见性。

在实现有序性的时候,volatile在实现上是通过限制编译器重排序,指令集重排序实现的。volatile规定了重排序的规则:

从这个表格上我们可以看出以下几点:

  1. 第二个操作是volatile写时,第一个操作无论是啥都不能重排序这个操作保证写前和写后的不会顺序错乱,写前的不会在写后操作。
  2. 第一个操作是volatile读时,第二个操作无论是啥都不能重排序。这个保证volatile读的顺序,读后的不会到读前面。
  3. 第一个操作是volatile写,第二个操作是volatile读时,不能重排序这个保证volatile写在读之前。

Java编译器在实现上面规则的时候,会使用内存屏障指令来实现。具体的实现规则如下:

  1. 在每个volatile写操作的前面插入一个StoreStore屏障,后面插入一个StoreLoad屏障,用来完成对写的保护。 
  2. 在每个volatile读操作的后面插入一个LoadLoad屏障LoadStore屏障,用来完成对读操作的保护

编译器虽然增加了内存屏障指令,但是有些cpu产商会把这些屏障再进一步优化,不过这个优化实际上是能够保证先后顺序的。通过这种方式实现有序性。

 

了解synchronized关键字么?什么是锁升级?Synchronized的三种使用方式?如果synchronized锁住的静态方法和非静态方法的区别是什么?

自java6之后,synchronized关键字被优化,其性能基本持平Reentrylock。在优化后的synchronized有了锁升级的理念,其主要表现在:无锁-》偏向锁-》轻量级锁-》重量级锁。加锁的过程会随着线程竞争的激烈程度而不断加深,直至重量级锁,一旦锁升级之后,就不会降级。如果了解过锁原理的话就会知道加锁的过程实际就是获取某个对象的过程,一旦有某个线程优先获取到该对象,其他线程就只能等待,所以锁一定是锁住某个对象。Synchronized也是类似,其偏向锁和轻量级锁的实现是通过修改对象头内容实现的,重量级锁则是通过对象监视器实现的。稍微说下,一个java对象头中包含markword,class metadata address ,array length等内容,synchronized修改的是markword字段。下面具体说下几种锁的实现原理:

 

  1. 偏向锁的原理:在加锁的过程中,HotSpot的研究者发现绝大部分的时候是不发生竞争的,换句话说就是只有一个线程想要这个锁,并且占用到这个锁的线程往往是同一个,那就意味着我们只要记录下加锁的线程是不是这个线程就可以了,如果是这个线程,就直接可以占用到锁。这样我们都不用使用CAS操作就能保证锁住的是同一个线程。Synchronized的偏向锁的原理就在于此,在上面的锁mark word字段中偏向锁是记录了占用锁的id,这样方便每次判断是不是同一个线程进行锁的占用。偏向锁在占用的时候,使用比较简单,但是如果当另外一个线程也来占锁的话,那这个时候,就必须开始锁撤销,偏向锁的撤销过程相对复杂,它需要在一个全局安全点(这个需要了解jvm,在垃圾回收的时候,也需要进入到安全点)开始进行:暂停持锁线程,判断该线程是否还存活,如果不存活的话,就直接置为无锁,如果还存活,那就需要遍历栈中的锁记录,如果该线程不需要锁,那么就置为无锁或者偏向其他线程,如果该线程还需要锁,那么就需要产生锁竞争,实际上这个时候偏向锁已经不合适了,会被标记为不合适,最后唤醒线程。
  2. 轻量级锁的原理:线程会先申请一个栈用来保存锁记录空间,将对象头中的锁mark word信息复制到栈中,然后CAS将锁markword信息设置为锁记录指针。如果设置成功,就占锁成功,如果设置失败,就自旋。撤销的过程也很简单,CAS替换回原来的mark word信息
  3. 重量级锁的原理:在java中,每个对象都有与之对应的一个monitor对象,重量级锁采用对象监视器实现,通过monitorentry和monitorexit实现,其实如下:如果对象的monitor进入数是0的话,则直接持有,并且设置owner为当前线程;如果线程占用了monitor对象,则可以重复进入,并且将monitor数值加1;如果有其他线程占用,那么当前线程会被阻塞,直到monitor数值为0 的时候唤醒开始重试占用,这个和AQS基本实现类似了

 

synchronized关键字的常见使用方式:锁住代码块,锁住静态方法,锁住非静态方法。具体的三种方式如下:

Object lock = new Object();//synchronized后面加一个对象
public void testLock() {
synchronized(lock){  //锁住代码块
}
}   
public static synchronized void testLock(){//Synchronized 放在一个静态方法里  
}   
  
public synchronized void testLockA(){//Synchronized放在一个普通方法里  
}  

在这三种方法中,代码块的使用方式锁住的是lock对象(也就是synchronized括号里面的对象),静态方法锁住的类对象,普通方法锁住的是持有这个方法的实例对象。从这里我们知道静态方法和非静态方法锁住的是不同的对象,所以这两者是可以有两个线程同时访问的,换句话说,静态方法和非静态方法不构成锁竞争

 

写一个线程安全的单例?

其实单例有很多种实现方式,如果感兴趣的话,可以看下公众号里面的并发系列文章中《聊聊单例的实现》,里面包含了绝大部分单例的实现方式。在这里主要是说下synchronized和volatile实现的方式。

public class Singleton {  
     private static volatile Singleton instance ;//使用volatile  
  
    private Singleton(){}  
  
    public static Singleton getInstance(){  
        if (null == instance){  
            synchronized (Singleton.class){//使用双重检查  
                if (null == instance){  
                    instance = new Singleton();  
                }  
            }  
        }  
        return instance;  
    }  
}  

在这里有两点需要说明的:(面试的时候也要重点提到)

  1. 为什么需要双重检查?

假设一下,如果是单次检查的话,有两种情况:

第一种:直接开始检查

 public static Singleton getInstance(){  
            synchronized (Singleton.class){ 
                if (null == instance){  
                    instance = new Singleton();  
                }  
            }   
        return instance;  
    }  

这个显然性能要慢很多,每个线程过来先加锁,即使是已经初始化完成的也要加锁,显然不可取,性能要差很多

第二种:检查后加锁

public static Singleton getInstance(){  
        if (null == instance){  
            synchronized (Singleton.class){  
                instance = new Singleton();  
            }  
        }  
        return instance;  
    }  

这种方法会造成instance不是单例,假设两个线程检查到instance都是null的话,其中一个先加锁成功,初始化instance,之后,释放锁,另一个线程再次加锁初始化instance。这样就造成了instance不是单例。显然也不满足要求

 

  1. 为什么instance要加volatile?不加会有什么问题?

加volatile主要是保证不会指令重排序的问题。不加的话有极小的概率可能会获取到一个instance是不可用的。我们看下为什么会发生这个情况,重点分析一下getInstance函数。

public static Singleton getInstance(){  
        if (null == instance){  //1
            synchronized (Singleton.class){//2  
                if (null == instance){  //3
                    instance = new Singleton(); //4 
                }  
            }  
        }  
        return instance;  
    }  

问题的根源在于上面的第4行代码,new一个对象。了解java类加载机制的同学可能会知道,新建一个类对象有几个步骤,申请空间,赋值等等。我们将第4行代码简化成下面几个伪代码。

 mem = memallocate//申请内存空间 5
 Initobject(mem)//初始化对象 6
 instance = mem //赋值 7

有些编译器将6和7进行指令重排序导致7在6之前,这样就有可能导致一个线程在执行代码7,另一个线程执行1发现instance!= null,立马将instance拿去用,但是这个时候的instance还没有执行6,也就是初始化没完成,instance是一个没有初始化的对象,可能就会导致问题的发生。如果加上volatile之后,就不允许指令6和7互换,从而避免这种问题的发生。

 

volatile和synchronized是面试的重点内容,请务必重视。其和可重入锁一起构成了java锁的核心,如果在面试中能够将这些内容描述正确,那基本上锁这块的内容就可以了。

本文的内容就这么多,如果你觉得对你的学习和面试有些帮助,帮忙点个赞或者转发一下哈,谢谢。

 

 

想要了解更多java内容(包含大厂面试题和题解)可以关注公众号,也可以在公众号留言,帮忙内推阿里、腾讯等互联网大厂哈

                                

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值