- 前言
从开始写Java到现在,从开始不知道锁是什么,怎么用,更不知道为什么要用。到现在能够在必要的场景下正确的使用一些锁。这过程中经历了对锁的不断尝试和理解,这篇文章就来做一下Java里面关于锁的一些简单的总结。
有错误的地方请指正,有没提到的内容请补充!
- 在Java中,什么情况下需要使用锁?
首先我们可以回顾一下之前的编码经历,什么情况下要使用锁呢?抛开那些加锁方法和代码块,我们按照内存分布的方式来理解下,如果多个程序会访问同一块儿内存中的数据,这种情况下
可能需要加锁。这块儿共享内存可以对应到我们Java中的全局变量、共享资源等等,总之就是大家(Java线程)共用的。
先举一个反例,看看下面的方法:
- public static String getString(){
- StringBuilder builder = new StringBuilder();
- builder.append('s');
- builder.append('h');
- builder.append('i');
- builder.append('t');
- return builder.toString();
- }
首先我们在方法中创建了一个实例,这个实例被分配到Java堆内存中。我们知道在Java内存分布中,栈内存是每个线程各自私有的,而堆内存是所有线程共用的,所以上面程序中的builder对应的对象处于所有线程共用的内存区域中,理论上可能被多个线程访问到。
但是,仔细思考一下,尽管builder对象被分配到了堆内存,但builder这个引用(类似句柄)只有在当前方法中有效。而且多个线程调用这个方法时,每个线程都会去new一个实例,都会有自己的builder引用(引用存在于线程的栈中),所以对于每个线程来说,只有它自己可以访问在堆内存中分配的builder,所以builder是不共享资源,上面的方法不需要加锁。
延伸:
这种情况也称为栈封闭,可以认为是线程安全的,不需要加锁。而且,这种情况下new的对象实例一定会分配在堆内存里面么?JVM中可能采取一些优化手段,比如逃逸分析(Escape Analysis),基于逃逸分析可能会进行栈上分配。也就是说,JVM会检测到,builder这个引用的生命周期只存在于上述方法范围中,不可能逃逸到方法外,所以可能就会直接将builder引用指向的对象分配到栈上了。
- public static final Map<String, String> MAP;
- static{
- Map<String, String> temp = new HashMap<String, String>();
- temp.put("a", "1");
- temp.put("b", "2");
- MAP = Collections.unmodifiableMap(temp);
- }
- public static String getName(){
- String px = "tim-";
- return px + MAP.get("a");
- }
很明显MAP是一个全局的共享资源,但是getName方法仍然不用加锁,为什么呢?因为虽然MAP是共享资源,但是它是不可变的,可能有多个线程访问它,但没有线程会修改它,所以这里也不需要加锁。
延伸:
我们会将这种不可变的域定义为常量,也就是说整个程序的运行过程中都不会去修改(写)这个常量。但是,就算是常量也要初始化吧,初始化也是写的过程,那怎么保证在初始化的时候不会有其他Java线程来读这个常量呢?所以一般常量都会由final修饰,可以保证安全发布(也就是说初始化过程中不会被其他线程读到),更多信息可以看下JSR-133中关于final域的重排规则。
- public static final Map<String, String> MAP_U;
- static{
- Map<String, String> temp = new HashMap<String, String>();
- temp.put("a", "1");
- temp.put("b", "2");
- MAP_U = Collections.synchronizedMap(temp);
- }
- public static String getNameU(){
- String px = "tim-";
- return px + MAP_U.get("a");
- }
很明显MAP_U是一个全局的共享资源,而且是可变的,我们不能保证其他线程不去修改MAP_U中的数据,所以访问这个共享数据的时候需要加锁。尽管我们方法中没有显示加锁,但MAP_U是一个线程安全的Map,get方法中已经加锁。
延伸:
尽管上面的MAP_U是线程安全的Map,但在某个复合操作下(比如判断没有则添加)还得额外加锁,如果需要原子的复合操作,请参见ConcurrenMap接口中提供的一些原子复合操作。
总结一下:当存在共享资源,且有线程会修改(写)这个共享资源时,那么对这个共享资源的访问(读写)都需要加锁。
- 如果获取锁失败,会有那些行为?
废话,获取锁失败后当然要等待了。
但具体怎么等待呢?有哪些细节?
我们知道Java中有很多锁,具体的等待细节也有所不同,但大体上等待方式可分两种:
自旋等待和
阻塞等待。从较底层的层面来说,自旋等待相当于当前的程序(进程)还在被调度执行,处理器还会执行程序的指令,但是这些指令表达的意思都是一直在不断(循环)尝试获取锁,直到获取成功,所以自旋等待也称为忙等待;而阻塞等待相当于当前程序不会被调度器调度了,处理器也不在执行它的指令了,一直到其他程序释放了锁,将其唤醒,它才会去再次尝试获取锁。
延伸:
Java线程一般都会映射到操作系统的进程,比如在Linux平台,Java线程会映射到Linux的线程(轻量级进程)。在Linux内核中,进程会由调度器来进行调度。这里简单说下CFS调度器,系统中所有可执行的进程在linux内核中组成一棵红黑树,CFS会从红黑树选择进程来调度。当进程阻塞后,会被从红黑树中转移到等待队列中,直到进程被唤醒,再次从等待队列中转移到红黑树中,才有可能再次被调度。
总结一下:如果程序获取锁失败,无外乎两种情况:程序继续被调度执行(不断重试);程序阻塞,不会被调度。
- 具体的锁是什么?
前面了解了锁的作用和一些行为,那么具体的锁是什么呢?
我们通过看一些Java中的锁机制来体会一下,首先最常用的就是synchronized关键字。
synchronized关键字给我们更多的感觉是语法层面的同步,只要有这个关键字,方法或者代码块儿中的代码就是线程安全的。但具体的锁是什么??
先看两个synchronized例子:
- private Map<String, String> cache = new HashMap<>();
- public synchronized void put(String k, String v){
- cache.put(k, v);
- }
- public void putV(String k, String v){
- synchronized (cache) {
- cache.put(k, v);
- }
- }
其实我们关注的锁,是一个对象,这里就叫锁对象吧。我们知道,用synchronized修饰实例方法的话,就相当于synchronized(this);修饰静态方法的话,就相当于synchronized(this.class)。可见,synchronized相关的代码最后都可以归结为是synchronized(Object)的形式,那么这个Object其实就是锁对象。
一般锁对象可以是我们要访问的共享资源对象本身,也可以是专门定义的一个锁对象,总之得是一个公共的对象,所有访问其保护资源的线程都能访问到的对象。
好吧,看看下面这个程序有什么问题:
- public void putVV(String k, String v){
- Object lock = new Object();
- synchronized (lock) {
- cache.put(k, v);
- }
- }
其次,ReentrantLock也是比较常用的锁机制。
相比synchronized关键字,ReentrantLock更容易理解。它本身就是一个锁(锁对象),我们会自然而然的创建好这个锁对象,然后执行加锁解锁等操作,不会像使用synchronized那样有时候不知道自己在干啥。
最后,在Java中有时候会使用一些基于CAS操作的自旋锁机制。
这些操作其实也是基于对一个数值的CAS等操作来进行加锁解锁过程,这个数值就相当于是锁对象。
总结一下:具体的锁可以看成是一个对象,这个对象会被能访问由锁保护资源的所有线程访问到。
- Java中提供了哪些锁?
synchronized:
内置锁,可重入。内部做了一些细致的优化,获取锁过程为:偏向锁->轻量级锁->自旋锁->重量级锁。
ReentrantLock:
基于AQS的可重入锁,比synchronized更加灵活。
ReentrantReadWriteLock:
基于AQS的可重入的读写锁。
SequenceLock:
基于AQS的可重入的顺序锁,在乐观读方法上要比ReentrantReadWriteLock高效一些。(这个类在jsr166e的extra包里发现,但貌似没出现在jdk里)
StampedLock:
不可重入的优化读写锁,针对乐观读做了优化,一般用于构建内部并发组件。
cas:
程序中可以通过CAS操作来构建一些锁,比如jdk1.7中ForkJoin框架中使用的scanGuard包含的顺序锁、jdk1.8中Striped64的cellsBusy锁等。
基于AQS构建的同步机制:
这些同步机制和锁机制也有着很多联系。
总结一下:Java中提供了各种各样的锁,合适的场景使用合适的锁。
- 使用锁有哪些注意事项?
1.正确的使用锁。
该用的时候用,不该用的时候不用。不要因为确定不了是否会出现并发问题,就把所有的方法都加上锁,要仔细分析可能出现并发的地方,在需要的时候加锁;也不要意识不到并发问题,让一些被共享的资源在无锁保护下裸奔,常见的比如使用一个公共的Random实例。
2.使用合适的锁。
前面我们简单介绍了那么多锁,在实际使用时要使用合适的锁。
3.锁的一些优化。
尽量减少锁的范围,值锁可能产生并发问题的代码;尽量减少锁的粒度,避免一些不必要的竞争,比如ConcurrentHashMap中的方式;按照实际情况控制锁行为,比如实际等待锁的时间很短(就是说等待时间比上下文切换时间还短),就没有必要阻塞,可以自旋一下。如果自旋超过一定次数,都让其进入阻塞状态,避免消耗过多的处理器资源。
- JVM在这方面做了哪些优化?
1.锁消除。
看个例子:
- public static String getStr(){
- StringBuffer buffer = new StringBuffer();
- buffer.append("1");
- buffer.append("2");
- buffer.append("3");
- return buffer.toString();
- }
前面已经提到过,这种情况属于栈封闭。同时我们知道,StringBuffer是一个线程安全的类,所有方法都是同步的。但很明显,这里的同步都是不必要的,所以JVM很可能会将代码中产生同步相关指令消除掉。
2.锁粗化。
看个例子:
- public static StringBuffer getStr(){
- StringBuffer buffer = new StringBuffer();
- buffer.append("1");
- buffer.append("2");
- buffer.append("3");
- return buffer;
- }
这种情况下,很明显buffer已经逃逸到方法外,没办法进行锁消除。但里面的3个append方法都会各自加锁解锁,实际上加一次锁(包含3个append)也可以,所以JVM很可能会将代码中的3个加锁解锁操作合并成一个。
3.偏向锁。
简单的说,就是在JVM内部,如果一个对象作为synchronized的锁对象,当一个线程获取这个锁时,会将线程id保存到这个锁对象的对象头上,当紧接着下一次申请获取这个锁的线程还是之前的线程时,只需要比较对象头中的线程id,不需要做其他锁相关的操作了。