美文网首页
Java中的锁

Java中的锁

作者: Eric新之助 | 来源:发表于2017-08-23 11:26 被阅读266次

    这是一篇关于java锁的总结的开端,后续会单独对部分锁的原理进行展开。内容大多来自《深入理解Java虚拟机》、《Java并发编程实战》和网络上。

    公平锁和非公平锁

    顾名思义,一个是不可抢占严格按照先到先得的锁,一个是可抢占的不公平的锁。
    公平锁是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;
    非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者要等很久才会获得锁。如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。
    公平锁可以使用new ReentrantLock(true)实现。

    可重入锁

    可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
    在JAVA环境下 ReentrantLock 和synchronized 都是可重入锁。可重入锁最大的作用是避免死锁。

    重入的一种实现方法是:为每个锁关联一个获取计数器和一个所有者线程。当计数器值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM记下锁的持有者,并将计数器置1,如果同一个线程再次获取到锁,计数器递增,当线程退出同步代码块时,计数器会相应地递减。当计数器值为0时,这个锁将被释放。
    不然当子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入锁,那么这段代码将会产生死锁,如:

    public class Widget {
        public synchronized void doSomething() {
        ...
        }
    }
    
    public class LoggingWidget extends Widget {
        public synchronized void doSomething() {
        super.doSomething();
        }
    }
    

    锁消除

    锁消除是虚拟机在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而能被其他线程访问到,那就可以把他们当做栈上数据对待,认为他们是线程私有的,同步加锁自然就无需进行。看例子:

    public String concatString(String s1, String s2, String s3) { 
        StringBuffer sb StringBuffer(); 
        sb.append(s1); 
        sb.append(s2); 
        sb.append(s3); 
        return sb.toString(); 
    }
    

    StringBuffer append方法的内部加了同步关键字:

    public synchronized StringBuffer append(Object obj) {
        toStringCache = null;
        super.append(String.valueOf(obj));
        return this;
    }
    

    也就是说在concatString()方法中涉及了同步操作。但是虚拟机观察sb变量的的作用域被限制在方法的内部,也就是sb的所有引用不会“逃逸”到concatString之外,其他线程无法访问到它。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

    锁粗化

    原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待的线程也能尽快拿到锁。大部分情况下,这些都是正确的。但是,如果一些列的联系操作都是同一个对象反复加上和解锁,甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也导致不必要的性能损耗。
    举个案例,类似锁消除的concatString()方法。如果StringBuffer sb = new StringBuffer();定义在方法体之外,那么就会有线程竞争,但是每个append()操作都对同一个对象反复加锁解锁,那么虚拟机探测到有这样的情况的话,会把加锁同步的范围扩展到整个操作序列的外部,即扩展到第一个append()操作之前和最后一个append()操作之后,这样的一个锁范围扩展的操作就称之为锁粗化。

    类锁和对象锁

    类锁:在方法上加上static synchronized的锁,或者synchronized(xxx.class)的锁,锁的范围是整个类class。如下代码中的m1和m2:

    对象锁:锁对象是当前类对象或者自定义锁对象,参考m3,m4,m5。

    public class LockClass { 
        public Object lock = Object(); 
        //类锁
        public static synchronized m1(){}  
        public m2(){ synchronized(LockClass.class){}} 
        //对象锁
        public synchronized m3(){}
        public m4() { synchronized(){} } 
        public m5() { synchronized(object1){} } 
    }
    

    自旋锁

    Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态装换需要耗费很多的处理器时间,对于代码简单的同步块(如被synchronized修饰的getter()和setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。

    在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间取挂起和恢复现场并不值得。
    如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下“,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

    自旋等待不能代替阻塞。自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋当代的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当使用传统的方式去挂起线程了。

    自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK6中已经变为默认开启,并且引入了自适应的自旋锁。自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

    自旋是在轻量级锁中使用的,在重量级锁中,线程不使用自旋。

    如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

    偏向锁、轻量级锁和重量级锁

    偏向锁是JDK6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。
    大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
    偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。

    当锁对象第一次被线程获取的时候,线程使用CAS操作把这个锁的线程ID记录再对象Mark Word之中,同时置偏向标志位1。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。

    如果线程使用CAS操作时失败则表示该锁对象上存在竞争并且这个时候另外一个线程获得偏向锁的所有权。当到达全局安全点时获得偏向锁的线程被挂起,膨胀为轻量级锁,同时被撤销偏向锁的线程继续往下执行同步代码。
    当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。

    线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录(Lock Record)的空间,并将对象头中的Mard Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。如果自旋失败则锁会膨胀成重量级锁。如果自旋成功则依然处于轻量级锁的状态。

    轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内都是不存在竞争的(区别于偏向锁)。这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。

    整个synchronized锁流程如下:
    1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
    2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
    3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
    4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
    5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
    6. 如果自旋成功则依然处于轻量级状态。
    7. 如果自旋失败,则升级为重量级锁。

    分段锁

    要降低锁的竞争程度,其中有一种方式是:减少锁的持有时间、缩小锁的范围、减少锁的粒度。
    这种技术可以采用多个相互独立的锁来保护共享资源来实现,这就是分段锁。然而这会提高程序的复杂度,而且使用的锁越多,发生死锁的风险也就越高。但是要做全局的统计功能时还是需要对共享资源进行全局加锁。
    ConcurrentHashMap中采用了分段锁。

    悲观锁和乐观锁

    悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作,简单地说是读写都加锁。
    乐观锁:假定不会发生并发冲突,只在提交操作时检测是否违反数据完整性。(写时加锁,使用版本号或者时间戳来配合实现,如CAS).

    死锁

    死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,他们都将无法推进下去。这是一个严重的问题,因为死锁会让你的程序挂起无法完成任务,死锁的发生必须满足一下4个条件:

    • 互斥条件:一个资源每次只能被一个进程使用。
    • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    • 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。
    • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

    避免死锁最简单的方法就是破坏循环等待条件。

    活锁

    LiveLock是一种形式活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都给对方让路,然而又在另一条路上相遇,就这样反复里避让下去,导致谁也过不去。

    读写锁、共享锁和排它锁、互斥锁

    略。

    相关文章

      网友评论

          本文标题:Java中的锁

          本文链接:https://www.haomeiwen.com/subject/kudddxtx.html