java 锁全面解析(一)

Java程序的并发离不开多线程,而涉及到多线程时,就需要考虑资源的共享性问题。jvm需要对两类线程共享数据进行协调:保存在堆中的实例变量和保存在方法区中的类变量。怎么协调?这一切都离不开锁。本次主要对Java中的锁做一些总结,包括:

一、一些常见的关于锁的基本概念

二、volatitle的使用

三、synchronized的使用

四、Lock包

五、ThreadLocal

一、一些基本概念

多线程的目的是为了让程序可以并行执行,运行更快,但并不是启动的线程越多,程序就运行越快。线程之间上下文的切换,死锁问题以及硬件和软件资源的限制,都可能是多线程运行减慢,甚至带来“灾难”。

上下文切换:CPU执行任务是通过时间分片来完成的,任务从保存到再加载的过程就是一次上下文切换。

原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性:即程序执行的顺序按照代码的先后顺序执行

happens-before原则:Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性

CAS:Compare and Swap,是java.util.concurrent包实现的根本。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS的实现是调用java 本地代码实现的,最终的实现是依托于处理的硬件编码。使用CAS可能会有三大问题:

1)ABA问题,解决方式加版本号。

2)循环时间长,开销大。解决方式是jvm提供pause指令。

3)只能保证一个共享变量的原子操作。解决方式是将多个共享变量合为一个。

减少上下文切换的常用做法:1)无锁并发编程; 2)CAS算法;3)避免创建不必要的线程;4)协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。


二、volatitle关键字

volatitle是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性”,即当一个线程修改了共享变量,另一个线程能读到修改后的值。volatitle使用得当会比synchronized成本更低,因为它不会引起线程上下文的切换和调度。

1、volatitle的实现原则

有volatitle修饰的变量进行写操作的时候,编译会产生Lock前缀指令。1)LOCK前缀指令会引起处理器缓存写回到内存;2)一个处理器的缓存写回到内存,会导致其他处理缓存无效。

2、volatitle的局限

volatitle能保证变量修改后被其他线程可见,但是它并不能保证对变量操作的原子性。如对volatitle变量的自增操作,读取变量到缓存,加1操作,写变量到内存,这三个步骤并不是CAS的,所以,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存看,这三个子操作可能会分割开执行,就有可能导致与预期不符的结果。


三、synchronized关键字

当一个线程试图访问同步代码块时,他首先必须得到锁,退出或者抛出异常时必须释放锁。从JVM规范中可以看到JVM基于入和退出Monitor象来实现方法同步和代码块同步,但两者的实现细节不一 。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法和异常JVM要保每个monitorenter对应monitorexit与之配。任何象都有一个monitor与之关,当且一个monitor被持有后,它将定状

synchronized的锁是存在Java对象头里。如果对象是数组,虚拟机用3个字宽存对象头,如果是非数组对象,则2个字宽。java 对象具体可以分为:Mark Word一个字宽,存储对象HashCode,分代年龄和锁位标记;一个字宽存储到对象类型数据的指针;如果是数组,一个字宽记录数组的长度。

1、synchronized关键字的使用

synchronized关键字锁具体表现为:锁对象和锁对象的class类;每个类可以有很多实例对象,不同实例对象的对象锁互不干扰,但每个类只有一个class对象,所有类只有一把公用锁。具体使用有:

     1) 修饰一个代码块,作用对象是这个方法所在的对象实例;

public  void run() {
      synchronized(this) {
         for (int i = 0; i < 5; i++) {
            try {
               System.out.println(Thread.currentThread().getName() + ":" + (count++));
               Thread.sleep(100);
            } catch (InterruptedException e) {
               e.printStackTrace();
            }
         }
      }

       2)修饰一个方法,作用对象是方法所在的对象实例;

public synchronized void run() {
   for (int i = 0; i < 5; i ++) {
      try {
         System.out.println(Thread.currentThread().getName() + ":" + (count++));
         Thread.sleep(100);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
   }
}

       3)修饰一个静态方法,作用对象是静态方法所在的类;

public synchronized static void run() {
   // todo
}

       4)修饰一个类,作用对象是这个类,作用范围是synchronized对于的块。

 public void method() {
      synchronized(ClassName.class) {
         // todo
      }
   }
注意:

1)如果synchronized作用于对象,如果对象不同,持有锁也会不同;

2)作用于类,虽然对象不同,但是类对象只有一个,该类所有的对象持有同一把锁。

3)synchronized关键字不能被继承。

4)如果一个类中同时包含对象锁和类锁,类锁和对象锁是两个不一样的锁,控制着不同的区域,它们是互不干扰的。

5)synchronized的代价较高,使用不当容易产生死锁,慎用。

 2、synchronized可重入性

重入锁的实现方法是为每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,任何线程都可能获得该锁调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。

3、synchronized锁的实现方案(来源http://www.cnblogs.com/longshiyVip/p/5213771.html)

SunHotSpot JVM实现中,synchronized锁还有一个名字:对象监视器。当多个线程一起访问某个对象监视器的时候,对象监视器会将这些请求存储在不同的容器中。

1)  Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中

2)  Entry ListContention List中那些有资格成为候选资源的线程被移动到Entry List

3)  Wait Set:调用wait方法被阻塞的线程被放置在这里

4)  OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck

5)  Owner:当前已经获取到所资源的线程被称为Owner

具体关系如下图:


  ContentionList并不是真正意义上的一个队列。仅仅是一个虚拟队列,它只有Node以及对应的Next指针构成,并没有Queue的数据结构。每次新加入Node会在队头进行,通过CAS改变第一个节点为新增节点,同时新增阶段的next指向后续节点,而取数据都在队列尾部进行。


JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交个OnDeckOnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。

OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。

处于ContentionListEntryListWaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。该线程被阻塞后则进入内核调度状态,会导致系统在用户和内核之间进行来回切换,严重影响锁的性能。

4、synchronized锁的升级

java1.6以后为了减少获取锁和释放锁的性能消耗,引入了偏向锁和轻量级锁。锁的状态一共有四种:无锁,偏向锁,轻量级锁和重量级锁。锁可以升级,但是不能降级,目的是为了提高获得锁和释放锁的效率。

1)偏向锁

实际情况表明,锁不仅不存在多线程竞争,而且总是由同一线程多次获取。当一个线程获取同步块时,会在对象头和栈帧中记录锁偏向的线程ID,以后进入和退出同步块时,不需要进行CAS操作来加锁,只需要简单的比较一下对象头的Mark word是否存储着指向当前线程的锁偏向。如果成功,表示线程已经获得了锁;如果失败,则看锁标记是否有锁,如果无锁,则使用CAS竞争锁;如果有,则尝试使用CAS将对象头里的偏向锁指向当前线程。

偏向锁的撤销:暂停所有有偏向锁的线程,然后检查持有偏向锁的线程是否存活;如果不存活,将对象头设置为无锁状态;如果存活,持有偏向锁的栈执行,然后遍历所有的偏向对象的锁记录,栈中的锁记录和对象头里的Markword要么偏向其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁。

     2)轻量级锁

如果获取偏向锁失败,则膨胀为轻量级锁。线程在行同步之前,JVM会先在当前线程的栈桢建用于存储锁记录的空,并将中的Mark Word复制到锁记录中,官方称Displaced Mark Word。然后线尝试使用CAS中的Mark Word换为指向锁记录的指。如果成功,当前线,如果失,表示其他线,当前线程便尝试使用自旋来

锁时,会使用原子的CAS操作将Displaced Mark Word回到,如果成功,表示没有生。如果失,表示当前存在争,就会膨成重量级锁
3)自旋锁

为了缓解线程切换性能问题,JVM引入了自旋锁。原理是:线程在竞争锁时,如果有线程持有自旋锁等待竞争锁的线程可以自旋而不是立即阻塞,当Owner线程释放锁后可立即获取锁,进而避免用户线程和内核的切换。但是持锁者可能执行的时间会超过设定的阈值,竞争线程会停止自旋进入阻塞状态。基本思路就是先自旋等待一段时间看能否成功获取,如果不成功再执行阻塞,尽可能的减少阻塞的可能性,这对于占用锁时间比较短的代码块来说性能能大幅度的提升!

JVM对于自旋周期的选择,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化。Synchronized在线程进入ContentionList时,等待的线程就通过自旋先获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

4)可重入锁

现在基本上所有的锁都是可重入的,即已经获取锁的线程可以多次锁定 /解锁监视对象,但是按照之前JVM的设计,每次加锁解锁都采用CAS操作,而CAS会引发本地延迟(下面会讲原因),因此偏向锁希望线程一旦获取到监视对象后,之后让监视对象偏向这个锁,进而避免多次CAS操作,说白了就是设置了一个变量,发现是这个线程过来的就避免再走加锁解锁流程。
5、synchronized关键字的缺陷

synchronized关键字释放锁:1)同步块执行完成;2)线程执行发生异常,此时JVM会让线程自动释放锁。

但是,如果同步块没有执行完成,也没有发生异常,而是因为IO或者其他原因阻塞,则锁不会释放,其他需要获取锁的线程也获取不到锁,会严重影响性能。


文章来源:

深入浅出Java并发包—锁机制(一)

《java 并发编程艺术》



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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值