Java中的锁-浅析

一、锁的解析

    在 Java 中,我们通过 synchronized 关键字来保证原子性,这是因为每一个 Object 都有一个内置的对象锁(Intrinsic Lock),也叫做对象监视器(Monitor Lock)。Java 有着内置的锁机制,它包含两部分:一个作为锁的对象的引用(内置锁),一个作为由这个锁保护的代码区域(synchronized 关键字指定范围)。在进入 synchronized 范围之前自动获得内部锁,一旦离开此范围,无论是任务完成或是线程中断都会自动释放该内部锁,而且获得该内置锁的唯一途径就是进入由这个锁保护的代码区域。

    这显然是一个互斥的独占的,而且它也是可重入的。一次只允许一个线程持有某个特定的锁,如果一个线程尝试获取一个由自己持有的锁时那么这个请求就会成功。锁对同一个线程来说是可重入的,不会出现自己把自己锁死的问题。反过来讲,如果没有锁的可重入性,对同一个线程而言那么将产生死锁。

    锁的可重入性一种实现方案是:为每一个锁关联一个计数器和所有者线程,当计数为0时就认为这个锁是未被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将锁关联的计数器计数加1。如果同一个线程再次获取这个锁时计数值将递增,当线程退出同步的代码区域时,计数值将递减。在JVM中,synchronized 关键字经过编译后,在同步的代码块前后分别形成 monitorenter 和 moitorexit 这两个字节码指令。在执行 monitorenter 指令时尝试获取该对象的内置锁,如果该对象没有被锁定或是当前线程已经拥有了那个内置锁,则锁的计数器加1,相应的在执行monitorexit指令时锁的计数器减1。当计数器为0时,锁就释放了。如果获取对象锁失败,则线程阻塞等待,直到对象的内置锁被另一个线程释放为止。

    锁还具有可见性(visibility):它必须保证释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。事实上,如果没有锁机制提供的这种可见性保证,线程看到的共享变量可能是修改前的或不一致的。一个方法是否声明为同步取决于临界区访问(critial section access),而锁(lock)是作为用于保护临界区的一种机制,但锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息,wait()、notify()、notifyAll()都是java.lang.Object的底层方法。

 

二、锁的使用

    1.  生产-消费者问题

        生产-消费者模型(Producer-Consumer Model),也称作有界缓冲区问题(bounded-buffer Problem)模型,是多线程同步问题的经典模型。它描述的是有一块缓冲区作为仓库,生产者可以生产然后放入仓库,消费者可以从仓库取出产品进行消费,而这个模型的关键就是要保证生产者不会在缓冲区满时放入数据,消费者也不会在缓冲区中空时消费数据。

        它的优点在于:

  • 均衡:如果消费者直接从生产者这里拿数据,那么当生产者生产速度过慢而消费者消费速度过快,消费者阻塞而白白浪费CPU时间。我们将生产和消费活动拆分成两个独立的并发体,并在中间用“仓库”做缓冲,生产者负责将产出的数据放入仓库,消费者负责从仓库取出数据进行消费,缓冲区满了不生产,缓冲区空了不消费。这使得,生产者和消费者的处理能力达到一个动态的平衡点,我们平衡生产者和消费者的处理能力来提高整体的处理速度,这是生产-消费者模型的主要作用。
  • 解耦:因为有了“仓库”,生产者和消费者并不直接依赖,也不需要互相关心。生产者只需要关心这个“仓库”,并不需要关心具体的消费者,对于生产者而言甚至都不知道有这些消费者存在;而对于消费者而言它也不需要关心具体的生产者,他只要关心这个“仓库”中还有没有产品可以消费。这是生产-消费模型的附带作用——复合、解耦。

        如果实现模型的方法不够完善,则可能会出现全部线程都陷入休眠,等待对方唤醒自己,即“系统假死”问题。它大多出现在非单一生产者/消费者的场景下,由于notify()的唤醒是任意的(选择此对象上等待队列中的某一个),那么理论上可能会出现这样一种情况:假如有生产者A、B并WAITING,消费者发现缓冲区空了,通知生产者A生产然后自己WAITING,生产者A生产完后本应该通知消费者结果却唤醒了生产者B,生产者B发现缓冲区满了,于是WAITING。至此,系统假死。从上述的案例中你可以看出,系统假死的原因在于notify的是同一个对象。所以,在非单一生产者/单消费者的场景下,预防假死的方法:使用notifyAll()唤醒所有线程,或是定义不同的Condition。

 

    2.  虚假唤醒

        在没有被通知、中断或超时的情况下,线程还可以被唤醒,即所谓的“虚假唤醒”(spurious wakeup)。

        这个问题的实质在于:阻塞API采用轮询的方式来监测是否有中断调用,在某些情况下一些wait会在除了notify和notifyAll的其他情况被唤醒,而此时是不应该唤醒的。虽然这种情况在Java实践中很少发生,但是应用程序可以通过以下方式防止其发生,即对导致该线程被唤醒的条件进行测试,如果不满足该条件则继续等待。等待应总是发生在循环中。也就是说,解决的办法是基于while来判断进入正常操作的临界条件是否满足。

        这在Linux帮助中提到过:在多核处理器下,pthread_cond_signal可能会激活多于一个线程(阻塞在条件变量上的线程),结果是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回,这种效应称为'虚假唤醒'(spurious wakeup)。虽然'虚假唤醒'在pthread_cond_wait函数中可以解决,但为了发生概率很低的情况而降低边缘条件(fringe condition)效率是不值得的,纠正这个问题会降低所有基于它的更高级的同步操作的并发度。所以在pthread_cond_wait的实现上没有去解决它。

        通常的标准解决办法是这样的:将条件的判断从if改为while,pthread_cond_wait中的while()不仅仅在等待条件变量前检查条件变量,实际上在等待条件变量后也要检查条件变量。这样对condition多做一次判断,即可避免'虚假唤醒'。

03215956_dJqb.gif

        有意思的是这个问题也存在几乎所有地方,包括:Linux 条件等待的描述,POSIX Threads的描述,Window API(condition variable)和JAVA虚拟机等等。注意:即使是'虚假唤醒'的情况,线程也是在成功锁住互斥后才能从condition_wait()中返回。即使存在多个线程被'虚假唤醒',但是也只能是一个线程一个线程的顺序执行。

 

    3.  信号丢失和唤醒(Notify)

        JDK规定了在synchronized内使用wait、notify和notifyAll,这是语言的要求。它确保了 wait() 和 notify() 被正确的串行化执行(线程被唤醒时只能有一个线程在执行),从实际效果来看,这消除了竞争条件,避免了不确定的“挂起”线程丢失 notify 消息而仍保持挂起。但某些情况下,我们仍会丢失notify消息,当信号来临时,有可能当前没有线程处于等待状态,通知信号过后便丢弃了,这就是所谓的“信号丢失”。

        因此,如果一个线程先于被通知线程调用wait()前调用了notify(),等待的线程将错过这个信号,这可能使等待线程永远在等待,不再醒来,因为线程错过了唤醒信号。这可能是也可能不是个问题,但这有可能会导致死锁的发生。

        有一个原则是:永远在循环(loop)里而不是if语句下使用wait 。在while循环里使用wait的目的,是在线程被唤醒前后都持续检查条件是否被满足,并在条件实际上并未改变的情况下处理唤醒通知。如果条件并未改变,wait被调用之前notify 唤醒通知就来了,那么这个线程有可能被错误地唤醒,你的程序就有可能会出错。

        thread.notify()和thread.notifyAll()方法的区别在于:调用notify时,JVM从等待队列中的一个线程进行唤醒;调用notifyAll时,等待队列中所有线程都将唤醒。共同点在于,将该监视器上一个或多个线程退出waiting状态而变成blocked状态(Thread有六种状态,new,runnable,blocked,waiting,timed_waiting,terminated),阻塞并等待该对象上的锁,一旦该对象被解锁,它们就会去竞争。就是说在wait状态之前,它们等待的是被notify或notifyAll,而不是锁。那么理论上会出现这种情况:

线程执行完后,所有线程都处于等待状态,这时如果唤醒的仍是wait方法的线程,循环条件为true,则唤醒的线程也将处于等待,因为没有了运行的线程来唤醒它们,所以发生了死锁。

        一般为了安全性,我们在绝大多数时候应该使用Object.notifiAll(),除非你明确知道只唤醒其中的一个线程。而通常,只有同时满足这两个条件时才能使用notify:

  • 所有等待线程的类型都相同。等待队列只与一个条件变量相关,并且所有的线程在唤醒后执行的都是相同的操作;
  • 单进单出。对条件变量的每个通知,要求只能最多唤醒一个线程;

 

    4.  停止、破坏、暂停和复活

        虽然Thread类的stop、destroy、suspend和resume方法都是过期作废的方法,但还是有研究它们为什么过期作废的原因和取代的方法,这很有意义。

            a )   停止(stop):

            thread.stop()可以停止某个线程,比如当前线程或其他线程,也可以停止一个尚未started的线程,效果是当该线程启动后就立马结束了

            为什么stop()方法被弃用?因为它具有固有的不安全性。它会使线程不安全,它会直接终止run方法的调用并抛出一个ThreadDeath错误,如果该线程持有某个对象锁的话立即释放锁,这可能会导致对象状态的不一致。换句话说,如果强制让线程停止则有可能使一些清理工作得不到完成,或是导致数据得不到同步处理,出现数据不一致的问题。

            这在JDK帮助中提到过:stop一个线程将释放它已经锁定的所有监视器(作为沿堆栈向上传播的ThreadDeath异常的一个自然后果)。如果以前受这些监视器保护的对象处于一种不一致的状态,则该对象将对其他线程可见,这种对象被称为受损的 (damaged)。当线程在受损的对象上进行操作时,这有可能导致任意的行为。这种行为可能微妙且难以检测,也可能比较明显,它不像其他未受检的(unchecked)异常,ThreadDeath 悄无声息的杀死线程,用户得不到程序可能会崩溃的警告,而崩溃会在真正破坏发生后的任意时刻显现,在数小时甚至数天之后。该方法的附加危险是它可用来生成目标线程未准备处理的异常(包括不可能抛出的检查异常)。难道我不能捕获 ThreadDeath 异常来修正被损坏的对象吗?也许在理论上,它会使编写正确的代码任务复杂化。线程可以在几乎任何地方抛出 ThreadDeath 异常,这使得所有的同步方法和同步块必须被考虑得事无巨细,线程在清理第一个 ThreadDeath 异常的时候(在 catch 或 finally 语句中),可能会抛出第二个,意味着清理工作将不得不重复直到其成功,保障这一点的正确代码将会相当复杂。总之,它是不切实际的。(JDK帮助: Why are Thread.stop,Thread.suspend and Thread.resume Deprecated?

            b )   破坏(destroy):

            thread.destroy(),这个方法没干什么事情,始终会抛出NoSuchMethodError,该方法无法终止线程,不要望文生意。 thread.destroy() 从未被实现,如果它被实现了,它将和 thread.suspend() 一样易于死锁(事实上,它大致上等同于没有后续 thread.resume 的 thread.suspend)。

            thread.destroy 方法最初用于破坏该线程,但不作任何清除。它所保持的任何监视器都会保持锁定状态。如果目标线程被破坏时保持一个保护关键系统资源的锁,则任何线程在任何时候都无法再次访问该资源(也就是说易于死锁)。这类死锁通常会证明它们自己是“冻结(frozen )”的进程。

            Java没有立即终止线程的机制,Thread类提供的destroy和stop方法都无法正确终止线程,只能通过标志(在run方法中记一个标记来进行结束)或者interrup 中断来进行。调用 thread.interrupt() 可以中断一个正处于阻塞状态的线程,该方法会设置线程的中断状态,如果线程在调用Object 类的wait方法或者该类的 join、sleep方法过程中被阻塞,则其中断状态将被清除,程序还将收到一个 InterruptedException。但它无法中断(标志中断)一个不处于活动状态的线程。

            c )   挂起(suspend)和恢复(resume):

            thread.suspend()和thread.resume(),一样的具有固有的死锁倾向。

            suspend和resume方法的缺点:不同步,独占。容易出现因为线程的挂起而导致数据不同步的情况,也极易造成公共同步对象的独占,使得其他线程无法访问公共同步对象。

            比如,当程序运行到System.out.println()方法内部停止时,监视器未被释放,这导致当前System的PrintStream对象的println()方法一直处“挂起”状态,并且锁未释放。这意味着,以后的System.out.println()方法将不能执行打印,且永远获取不到任何监视器。

            我们可以用“让目标线程轮询一个指示线程期望状态(活动或挂起)的变量”来取代它。当期望状态是挂起时,线程 Object.wait 来等待;当恢复时,用 Object.notify 来通知目标线程。

 

    5.  死锁、饥饿和活锁

            a )   死锁:

            两个或更多线程阻塞着等待其它处于死锁状态的线程所持有的锁,被称为“死锁”。死锁通常发生在多个线程同时但以不同的顺序请求同一组锁的时候。就JavaAPI而言,线程死锁可能发生在以下情形:

  • 当两个线程相互调用 thread.join;
  • 当两个线程使用嵌套的同步块,一个线程占用了另外一个线程必需的锁,互相等待时被阻塞时;

           由于被允许执行的线程首先必须拥有对锁资源的排他性的访问权,在使用“synchronized”关键词时,很容易出现两个线程“互相等待”对方做出某个动作的情形,但是大多数的死锁不会那么显而易见,需要具体情况具体分析。我们可以借助于线程分析工具,比如JDK自带的JProfiler、JCA(Java EE Connector Architecture)或jstack 命令来追踪分析死锁的发生。

            一般来说,要产生死锁需要满足以下条件:

                ① 互斥条件:一个资源每次只能被一个进程使用;

                ② 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;

                ③ 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺;

                ④ 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系;

                22231132_kNMT.png

            死锁是由上面四个必要条件导致的,所以一般来说,只要破坏这四个必要条件中的一个,死锁情况就不会发生。可实际情况并不简单:

  1. 如果想要打破互斥条件,我们需要允许进程同时访问某些资源,这种方法受制于实际场景,不太容易实现;
  2. 打破不可抢占条件,这样需要允许进程强行从占有者那里夺取某些资源,或者占有资源的进程不能再申请占有其他资源,必须释放手上的资源之后才能发起申请,这个其实也很难找到适用场景;
  3. 进程在运行前申请得到所有的资源,否则该进程不能进入准备执行状态。这个方法看似有点用处,但是它的缺点是可能导致资源利用率和进程并发性降低;
  4. 避免出现资源申请环路,即对资源事先分类编号、按号分配,这种方式可以有效提高资源的利用率和系统吞吐量,但是增加了系统开销,增大了进程对资源的占用时间。

            那么,如果考虑消除死锁的其它方式呢:

     1)  最简单的方式就是系统重启。这种方法成本很高,它意味着在这之前所有进程已完成的计算工作都将付之东流,包括参与死锁的那些进程,以及未参与死锁的进程;

     2)  撤消进程,剥夺资源。终止参与死锁的进程,收回它们占有的资源,从而解除死锁。这又可以分两种情况:一次性撤消参与死锁的全部进程,剥夺全部资源;或者逐步撤消参与死锁的进程,逐步收回死锁进程占有的资源。一般来说,选择逐步撤消的进程时要按照一定的原则进行,目的是撤消那些代价最小的进程,比如按进程的优先级确定进程的代价;考虑进程运行时的代价和与此进程相关的外部作业的代价等因素;

     3)  进程回退策略。让参与死锁的进程回退到没有发生死锁前的状态,并由此处继续执行,以求再次执行时不再发生死锁。虽然这是个理想的办法,但是操作起来系统开销很大,要有堆栈这样的机构记录进程的每一步变化,以便以后的回退,有时这是无法做到的。

            有一种叫做“隐性死锁”,它是由于不规范的编程方式引起,但不是每次测试运行时都会出现死锁的情形。由于这个原因,一些隐性死锁可能要到应用正式发布之后才会被发现,因此它的危害性比普通死锁要大。一般有两种导致隐性死锁的情况:
     ● 加锁次序:
        当多个并发的线程分别试图同时占有两个锁时,会出现加锁次序冲突的情形。如果一个线程占有了另一个线程必需的锁,就有可能出现死锁。
     ● 占有并等待:
        如果一个线程获得了一个锁之后还要等待来自另一个线程的通知,可能出现另一种隐性死锁。有时“占有并等待”还可能引发一连串的线程等待,例如,线程A占有线程B需要的锁并等待,而线程B又占有线程C需要的锁并等待等。

            避免死锁的一个通用的经验法则是:当几个线程都要访问共享资源A、B、C 时,保证使每个线程都按照同样的顺序去访问它们。此外,Thread 类的suspend()方法也很容易导致死锁(固有的死锁倾向),因此这个方法已经被废弃了。

            要预防死锁的发生和减少同步带来的性能开销,需要具体问题具体分析。一般我们都要遵守的原则是:

  1. 考虑是否需要同步,是否可以使用不可变对象(Immutable)、本地引用(thread local)、原子变量(atomic包)或并发容器(concurrent包)来代替;
  2. 尽量减小锁的范围,因为有时我们需要保护的仅仅是对象的共享状态,而不是一个大的范围;
  3. 尽量缩短持有锁的时间,避免在临界区中进行耗时的计算;
  4. 降低请求锁的频率,比如锁拆分 (lock splitting)和锁分离 (lock striping) ,比如相互独立的状态变量应使用独立的锁进行保护,或者分成若干个加锁块的集合,它们归属于相互独立的对象,以减小了锁的粒度和竞争程度。

            b )  饥饿:

            一个或者多个线程因为各种原因无法获得所需要的资源而导致一直无法执行的状态,被称为“饥饿”。解决饥饿的方案被称之为“公平性” – 即所有线程均能公平(FIFO策略,先来先服务)地获得运行机会。

            导致饥饿的原因:

  • 高优先级线程吞噬低优先级线程的CPU时间(高优先级任务会获取更多的时间片);
  • 线程被长时间堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前对同步域进行访问。
  • 线程在等待一个本身也处于长时间等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是能被持续地获得唤醒。

            引发饥饿最常见的问题就是CPU时钟周期,一个可运行的进程尽管能继续执行,但被调度器无限期地忽视(得不到CPU时间片),而不能被调度执行的情况。经管可以指定线程优先级,但它只能作为操作系统进行线程调度的一个参考,换句话说就是操作系统在进行线程调度是平台无关的,会尽可能提供活跃性好的、抢占式的调度。那么即使在程序中指定了线程的优先级,也有可能在线程调度时映射到了同一个优先级的其它线程。通常情况下,不建议修改线程的优先级,因为一旦修改它程序的行为就会变成与平台相关,并且可能会导致饥饿问题的产生。

            Java的同步代码区对某个线程允许进入的次序没有任何保障,这就意味着理论上存在一个试图进入该同步区的线程处于被永久堵塞的风险,因为其他线程总是能持续地先于它获得访问,这即是“饥饿”,而一个线程被“饥饿致死”正是因为它得不到CPU运行时间的机会。

            多个线程阻塞在wait()方法上,如果对其调用notify()并不会保证某一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。换句话说有可能会存在这样一个风险:某个等待线程从来得不到唤醒,因为其他等待线程总是能被唤醒。

            c )   活锁:

            线程未被阻塞,由于某些条件没有满足,导致一直重复的尝试,失败,尝试,失败。当所有线程阻塞,或者由于需要的资源无效而不能处理,不存在非阻塞线程使资源可用,这就导致了活锁。就JavaAPI而言,线程活锁可能发生在以下情形:

  • 当所有线程在程序中执行参数为0的Object.wait方法,程序将发生活锁直到在相应的对象上有线程调用 Object.notify() 或者 Object.notifyAll();
  • 当所有线程卡在无限循环中;

            活锁不会被阻塞,而是不停检测一个长久不可能为真的条件。除去线程本身持有的资源外,活锁状态的线程会持续耗费宝贵的CPU时间。

            活锁和死锁的区别在于,处于活锁的线程是在不断的改变状态,所谓的“活”,而处于死锁的线程表现为互相等待,所谓的“死”;活锁有可能解开,死锁永远不能。活锁可以认为是一种特殊的饥饿。

 

 

三、锁的分类

    一个简单的 synchronized 过程,可能会涉及到独占锁、可重入锁、自旋锁、偏向锁、轻量锁和重量锁,还会涉及到公平非公平模式、死锁,这里我们摘取其中几个简单分析一下:

    1.  可重入锁:

        可重入锁(Reentrant Locking),也叫做递归锁,某个线程获得锁之后,仍然能尝试获取同一个锁。它最大的作用是避免死锁。在 Java 中,ReentrantLock 和 synchronized 都是可重入锁。

 

    2.  读写锁

        读写锁(Read Write Locking),读的地方使用读锁,写的地方使用写锁。在没有写锁的情况下,读是无阻塞的。但如果有一个线程正在进行写操作或请求获取写锁,就不能有其它线程对其进行读操作和写操作。也就是说,读-读共存,读-写、写-写不能共存。

     读写分离,且写操作没有读操作那么频繁时,这在一定程度上是可以提高效率的,比如 ReadWriteLock。但有一个问题需要注意:我们假设写操作的请求比读操作的请求更重要,如果读操作发生频繁,而我们又没有提升写操作请求的优先级,那么就会产生‘饥饿’——请求写操作的线程将一直阻塞,直到所有读操作的线程都释放该锁了。

        对 ReadWriteLock 类使用上还有一个细节:锁降级。ReadWriteLock 允许不释放写锁的情况下获取读锁,或者说在读锁操作下保持写锁(这也是 ReentrantReadWriteLock 是可重入锁的点)。反之,如果 reader 线程试图获取写锁,或是未释放读锁的情况下去申请写锁,那么将永远不会成功(死锁)。

        在读写上有时我们还会这么做:在更新时申请写锁,复制对象并修改,修改完毕后切换对象的引用,而读取则不加锁。这种方式被称为“消除读写操作时的互斥锁”,CopyOnWriteArrayList 就是COW的典型实现,可以明显提升读的性能。

 

    3.  独占锁和共享锁:

        独占锁(X锁) -- 也叫排他锁,每次只能被一个线程持有的锁。ReentrantLock 就是以独占方式实现的互斥锁。

        共享锁(S锁) -- 能被多个线程同时持有的锁。ReadWriteLock,CyclicBarrier,CountDownLatch 和 Semaphore 都是共享锁。

        很显然,独占锁是一种悲观的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程只能等待,这种情况下就限制了并发性,因为读操作并不会影响数据的一致性。而共享锁则是一种乐观的加锁策略,它放宽了加锁限制,允许多个执行读操作的线程同时访问共享资源。

        共享锁和独占锁的经典应用,是对共享文件的操作。Java的并发包中提供了 ReadWriteLock 读-写锁,它允许一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

 

    4.  悲观锁和乐观锁

        乐观锁(Optimistic locking):假设不会或很少会发生并发竞争。

        悲观锁(Pessimistic Locking):假设一定会发生并发竞争。

        乐观锁,就像它的名字一样,对于并发之间的操作产生的线程安全问题持乐观态度。乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-设置这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。悲观锁,也像它的名字一样,对于并发之间的操作产生的线程安全问题持悲观状态。悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁。

        阻塞同步是一种悲观的并发策略,它认为只要不去做正确的同步操作就肯定会出问题,无论资源是否会出现竞争。而非阻塞同步是一种乐观的并发策略,它基于冲突检测,如果没有其他线程竞争共享资源那操作就成功了,如果共享资源竞争了那就再进行其他的补偿措施(最常见的措施就是重试,直到成功为止)。因为这种策略不需要把线程挂起,所以这种同步被称为非阻塞的同步。

        比如在数据操作上,悲观锁模式下,对数据被外界修改持保守态度,因此,在整个数据处理过程中将数据处于锁定状态。悲观锁的实现,大多数情况下依靠数据库的锁机制以保证操作最大程度的独占性,但随之而来的是数据库性能的大幅开销,特别是对长事务而言,这样的开销往往是无法忍受的。相对悲观锁而言,乐观锁采取了更加宽松的加锁机制。在乐观锁模式下,认为数据一般情况下不会发生并发冲突,只在提交操作时才会对数据进行检测是否违反数据完整性。一般乐观锁有两种方式:使用数据版本(Version)记录机制或是时间戳(Timestamp) 机制实现。乐观锁不能解决数据脏读的问题。

 

    5公平锁和非公平锁:

        公平锁,按照 FIFO(先到先得)顺序公平的获取锁。而非公平锁则允许直接获取锁

        从实现上说,在公平锁场景下,当一个线程竞争某个对象锁时,只要这个锁的等待队列非空,就必须把这个线程阻塞并插入到队尾(插入队尾一般通过一个 CAS 操作保持插入过程中没有锁释放)。相对的,非公平锁场景下,每个线程都先要竞争锁,在竞争失败或已被加锁的前提下才会被加入 CLH(等待队列),在这种实现下,后到的线程有可能无需进入 CLH直接竞争到锁。

        它们的区别主要表现在,尝试获取锁的策略不同。公平锁在尝试获取锁时,即使锁没有被任何线程持有,它也会判断自己是不是 CLH 的表头,是的话才获取锁。而非公平锁在尝试获取锁时,如果锁没有被任何线程持有,则不管它在 CLH 的何处它都直接获取锁。

        使用公平锁的一个原因是为了防止饥饿,而非公平锁虽然可能导致饥饿,但能很大程度上提升锁的吞吐率(5-10倍)。Java concurrent 包默认都是非公平的,包括内置的对象监视器(synchronized 是一个典型的非公平锁,而且永远无法通过其他手段将其变为公平锁)。公平是有代价的,因为要确保公平就需要记帐和同步,就意味着被竞争的公平锁要比不公平锁的吞吐率低。

        ReentrantLock 对象在非公平锁模式下实现为,获取锁时先通过 compareAndSet()判断当前锁的状态是不是空闲,是的话就不排队直接获取锁。

 

    6.  自旋锁:

        自旋锁(Spin Locking),尝试获取锁的线程,在没有获得锁的时候不被挂起,而转而去执行一个空循环也就是自旋操作的锁。在若干个自旋后,如果还没有获得锁则才被挂起,获得锁则执行代码。

        如果锁竞争不是很激烈,而处理器阻塞一个线程引起的线程上下文切换的成本高于等待锁资源的成本时(比如锁占用时间很短),我们可以允许线程不放弃CPU处理时间,做忙循环,直到锁的持有者释放锁。这显然是一个非阻塞锁。

        由于自旋锁只是将当前线程执行自旋操作,不进行线程状态的变化,所以响应速度更快。但问题也是显而易见:自旋锁省去了阻塞使用的时间和空间(等待队列的维护等)开销,但是长时间的无用的自旋,显然还不如阻塞锁。

  • 当锁的持有者长时间不释放锁,那么等待者将长时间的占据CPU时间片,导致CPU资源的浪费。所以可以设定时间阈值,当锁持有者超过阈值而不释放锁时,等待者将放弃CPU时间片进入阻塞。
  • 当线程数不停增加时,由于每个线程都需要做忙循环,导致CPU占有率过高。所以,如果线程竞争不激烈,保持锁的时间段较少,可适用自旋锁。
  • 对于一个非可重入锁而言,如果某个线程在获得自旋锁后试图再次获得该锁,那么这时线程会一直等待自己释放该锁,这就引起了死锁。所以,在使用自旋锁应遵循以下原则:为了避免无用的自旋,程序不能在持有自旋锁时调用它自己,也决不能在循环调用时试图获得相同的自旋锁。

        自适应自旋锁,是 JDK1.6 引入的,通过 JVM 在运行时收集的统计信息(上一次在同一个锁上的自旋时间及锁的拥有者状态),动态调整自旋锁的自旋上界,使锁的整体代价达到最优。

 

    7.  偏向锁、轻量锁和重量锁:

        锁的四种状态:无锁,偏向锁,轻量锁,重量锁。随着锁的竞争,锁可以从偏向锁升级到轻量锁再升级的重量锁,但升级是单向的,只能从低升级到高而不会出现锁的降级。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。锁的四种状态是 JDK1.6 提出来的一种锁优化的机制,默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。

        锁存在于 Java 的对象头里。Java 对象头里的 MarkWord 里默认存储对象的 HashCode、分代年龄和锁标记位,且在运行期 MarkWord 存储的数据会根据锁标志位复用空间(为了在极小的空间里存储尽量多的信息):偏向锁时的 ThreadID,轻量锁时指向栈中锁记录的指针,重量锁时指向互斥量(操作系统底层的 MutexLock 接口)的指针。

            a )   偏向锁(Biased Locking):

            若程序没有竞争,则持有偏向锁的线程将永远无需触发同步,看起来让这个线程得到了‘偏护’的锁。如果在接下来的运行过程中有其他的线程强占锁,则持有偏向锁的线程会被挂起,JVM 尝试消除它身上的偏向锁(退出偏向模式),锁恢复到标准的轻量锁。

            当一个线程请求锁,如果 MarkWord 是空的,那么第一次获取锁的时候,线程将自身的线程ID写入到锁的 MarkWord,将是否偏向锁的状态位置为1。这样下次获取锁的时候,只需检查 ThreadID 是否和自身线程ID一致,如果一致则认为当前线程已经获得偏向锁,因此无需再次获取锁,略过了轻量锁和重量锁的加锁、解锁阶段,提高了效率;如果不一致则使用 CAS 操作竞争锁。

            当其它线程尝试竞争偏向锁,等待到达全局安全点(Safe Point,在这个时间点上没有字节码正在执行)时,JVM 首先会暂停偏向锁的持有者线程,检查该线程是否活着,如果线程不处于活动状态则将对象头置成无锁状态,如果线程仍然活着则拥有偏向锁的栈会被执行,遍历栈中的锁记录,将锁记录和 MarkWord 要么重新偏向于其他线程,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁的撤销

            当锁有竞争关系时,需要解除偏向锁进入到轻量锁阶段,线程进入锁竞争状态。

214703_anFn_1474911.png

            偏向锁其实是在单线程条件下可重入锁的简单实现。它可以提高带有同步但无竞争的程序性能,问题也是显而易见的:当出现多线程竞争情况时,偏向锁的撤销操作的性能损耗大于节省下来的 CAS 指令的性能消耗。

            b )   轻量锁(Lightweight Locking):

            轻量锁并不是用来代替重量锁的,它尝试在进入互斥前进行补救,它的本意是为了减少多线程进入互斥的几率,并不是要替代互斥。

           轻量锁获取:线程在进入互斥前,JVM 为当前线程分配lock record(存储锁记录的空间),将 MarkWord 复制到lock record中(这被称为 Displaced MarkWord,置换标记字)。然后尝试使用 CAS 操作将 MarkWord 轻量锁指针指向当前lock record,如果成功则当前线程获得锁,MarkWord 的锁标志位置为“00”,如果失败则表示其他线程竞争锁,当前线程将尝试使用自旋来获取锁。

            轻量锁解锁:持有轻量锁后执行完锁内的代码,然后通过 CAS 操作来将 Displaced MarkWord 替换回到对象头,如果成功则表示没有竞争发生,如果失败则表示当前锁存在竞争,锁就会膨胀成重量锁,MarkWord 的锁标志位置为“10”。

214953_9K2w_1474911.png

            轻量锁通过 CAS 操作检测锁冲突,在没有锁冲突的前提下,避免采用重量锁的一种优化手段。

215036_GClb_1474911.png

            加轻量锁的条件是当前对象没有发生锁竞争。轻量锁解锁和偏向锁解锁的区别:轻量锁是在有锁竞争出现时升级为重量锁,而偏向锁是在有不同线程申请锁时升级为轻量锁。

            轻量锁适用的场景是线程交替执行同步块的情况。

            c )   重量锁(Heavyweight Locking):

            重量锁在 JVM 中又叫对象监视器(Monitor),它的本质是依赖于底层操作系统的 MutexLock 来实现的。除了具备Mutex(0|1)互斥的功能,它还负责实现了 Semaphore (信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(Wait队列),前者负责做互斥,后一个用于做线程同步。

        引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量锁的执行路径,减少锁重入的开销,因为轻量锁的获取及释放依赖多次 CAS 指令,而偏向锁只需在置换线程ID时依赖一次 CAS 指令(CAS本身仍旧是一种操作系统同步原语,始终要在 JVM 与OS之间来回)。而轻量锁通过 CAS 操作来避免进入开销较大的重量锁。一旦出现了多线程竞争锁,偏向锁和轻量锁都会升级为重量锁。进一步的说,偏向锁和轻量锁都是重量锁的乐观并发优化。    

        总结:自旋锁是在发生锁竞争时自旋等待,自旋锁的前提是发生锁竞争,而偏向锁、轻量锁的前提是没有锁竞争,所以加自旋锁应当发生在加重量锁之前。准确地说是在线程进入 Monitor 等待队列之前,先自旋一会,重新竞争,如果还竞争不到,才会进入Monitor等待队列。

        根据《深入理解Java虚拟机》提到的“如果说轻量级锁是在无竞争的情况下使用CAS操作消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了”和“当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态”的理解,偏向锁是为了解决“大多数情况下锁不存在多线程竞争”,而不是解决所有的锁问题。因为偏向锁耗能要比轻量级锁要少,而且锁一旦升级是不能降下来的,因此 JVM 会尽可能维持低能耗锁。

        加锁顺序为:偏向锁—>轻量锁—>自适应自旋锁—>重量锁。

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。

 
    8.  锁粗化:

        锁粗化(Lock Coarsening),即将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。比如把邻近的 synchronized 块用相同的锁合并起来,以减少不必要的锁获取和释放。

        原则上,我们总是将同步域的作用范围尽量小,只在真正的实际的竞争作用域中才进行同步,这样做是为了使得同步操作数量尽可能的小,如果存在锁竞争,等待锁的线程也能尽快地拿到锁。这在大部分情况下都是正确的,但如果一连串的连续操作都对同一个对象反复加锁和解锁,甚至于加锁操作出现在循环体中的,那即使没有线程竞争,频繁地用户态内核态切换也会造成不必要的性能损耗。所以,如果 JVM 探测到这样的情况,将会把同步域的范围扩展到整个操作序列的外部,以优化性能。

 

    9.  锁消除:

        锁消除(Lock Elimination),即删除不必要的加锁操作。逸出分析 (escape analysis) 可以识别本地对象的引用是否在堆中被暴露(不会逃逸出去被其他线程访问到),则可以将本地对象的引用变为线程本地的 (thread local) 。

        锁消除、锁粗化是常见的JVM对锁的优化,除此之外还有:

  • 如果一个非竞争锁对象只能由当前线程访问,其他线程无法获得该锁并发生同步,那么 JVM 则去除对这个锁的请求。

 

 

 

参考自:

转载于:https://my.oschina.net/duofuge/blog/808675

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值