Java之锁的实现原理

synchronized实现原理

在这里插入图片描述

同步代码块的底层实现

先看一段代码:

class Test{
    public static void main(String[] args) {
      Object obj=new Object();
      synchronized(obj){
          System.out.println("hello world");
      }
    }
}

看他的底层实现,转汇编:右键点击你的建立的class文件->找到open in terminal->输入汇编指令java 类名.java ->javap -v 类名
看下图:
在这里插入图片描述
总结:
执行同步代码块后首先要先执行monitorenter指令,退出的时候执行monitorexit指令。通过分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即**同一时刻只有一个线程能够获取到monitor。**观察上述汇编指令我们发现有一个monitorenter指令以及多个monitorexit指令,这是因为Java虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。

同步方法的底层实现

范例:

class Test{
    public static void main(String[] args) {
        test();
    }
      public static synchronized void test(){
            System.out.println("hello world");
        }
}

汇编实现:
在这里插入图片描述

当使用synchronized标记方法时,字节码会出现访问标记ACC_SYNCHRONIZED。该标记表示在进入该方法时,JVM需要进行monitorenter操作。在退出该方法时,无论是否正常返回,JVM均需要进行monitorexit操作。这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

moniter机制

关于 monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,JVM会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为0的情况下,如果锁对象的持有线程是当前线程,JVM可以将计数器再次+1(可重入锁);否则需要等待,直到持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。
对象锁(monitor)机制是JDK1.6之前synchronized底层原理,又称为JDK1.6重量级实现,线程的阻塞以及唤醒需要操作系统有用户态切换到内核态,开销非常大,因此效率很低。

可重入锁的解释

先给大家讲个故事吧~在一个村子里面,有一口井水,水质非常的好,村民们都想打井里的水。这井只有一口,村里的人那么多,所以得出个打水的规则才行。村长绞尽脑汁,最终想出了一个比较合理的方案,井边安排一个看井人,维护打水的秩序。打水时,以家庭为单位,哪个家庭任何人先到井边,就可以先打水,而且如果一个家庭占到了打水权,其家人这时候过来打水不用排队。而那些没有抢占到打水权的人,一个一个挨着在井边排成一队,先到的排在前面。说了这么多这个和可重入锁有什么关系呢?不要着急,我们来看看下面这个场景。假如说线程A是第一个来井边打水的,他来了之后线程B,C,D…等才陆陆续续来,按照规则,理所当然线程A先打水(所状态+1),其他线程都等着,但是在线程A还在打水的时候,线程A的儿子来了,那线程A的儿子要不要排队等待呢?当然不行,因为这样就会死锁。所以当A再次请求锁,就相当于是打水期间,同一家人也来打水了,是有特权的(锁状态再次+1)。线程A每释放一次锁,锁状态-1,直到所状态为0时,锁被释放。下一个线程再来竞争锁。

提供的Lock锁

范例:使用ReentrantLock进行同步处理

class MyThread implements Runnable {
    private int ticket = 10;
    private Lock ticketLock = new ReentrantLock();
    public void run() {
        for (int i = 0; i <= this.ticket; i++) {
            ticketLock.lock();
            try {
                if (this.ticket > 0) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + ",还有" + this.ticket-- + " 张票");
                }
            } finally {
                ticketLock.unlock();
            }
        }
    }
}
class Test{
    public static void main(String[] args) {
      MyThread myThread=new MyThread();
      new Thread(myThread,"A").start();
     new Thread(myThread,"B").start();
     new Thread(myThread,"C").start();
    }
}

运行结果:
在这里插入图片描述
在JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的Lock对象,性能更高一些。

synchronized的优化

CAS操作
什么是CAS?

使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码(访问共享资源)都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。
CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

CAS的操作过程

CAS可以理解为CAS(V,O,N):
V:当前内存地址实际存放的值
O:预期值(旧值)
N:更新的新值
**当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。**当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试自旋),或者选择挂起线程(阻塞)。
注意:CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
内建锁(Synchronized)在老版本最大的问题在于:在存在线程竞争的情况下会出现线程阻塞以及唤醒带来的性能问题,这是一种互斥问题(阻塞同步)。而CAS不是讲线程挂起,当CAS失败后会进行一定的尝试操作并非耗时的将线程挂起(也就叫做非阻塞同步)

CAS的问题
  • ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,**添加一个版本号可以解决。**在JDK1.5后的atomic包中提供了AtomicStampedReference来因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。在JDK1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题。
    2.自旋会浪费大量的处理器资源
    与线程阻塞相比,自旋会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。JVM给出的方案是
    自适应自旋
    ,根据以往自旋等待时能否获取到锁,来动态调试自旋的时间(循环次数)。如果自旋时获取到了锁,则会稍微增加下一次自旋的时长,否则会稍微减少下一次自旋时长。
    3.公平性
    自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。所以内建锁无法实现公平机制,而lock体系可以实现公平锁。
Java对象头

JDK1.6之后一共四种状态锁,级别从低到高依次是:
无锁 0 01
偏向锁 1 01
轻量级锁 00
重量级锁 10
根据竞争状态的激烈程度,锁会自动进行升级,锁不能降级(为了提高获取与释放的效率)

偏向锁

1.偏向锁的由来:

   大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获取锁的开销降低引入偏向锁

偏向锁是锁状态中最乐观的一种锁:从始至终只有一个线程请求一把锁。
2.偏向锁的获取:

   - 测试对象头Mark Word(默认存储对象的HashCode,分代年龄,锁标记位)里是否存储着指向当前线程的偏向锁。
   - 若测试失败,则测试Mark Word中偏向锁标识是否设置成1(表示当前为偏向锁)
   - 没有设置则使用CAS竞争,否则尝试使用CAS将对象头的偏向锁指向当前线程

3.偏向锁的撤销:开销较大

- 偏向锁使用了一种等待竞争出现才会释放锁的机制,其他线程竞争偏向锁
- 暂停拥有偏向锁的线程,检查线程是否存活
- 处于非活动状态(即终止状态),则将锁对象的对象头设置成为无锁状态
- 存活,则重新偏向于其他线程或者恢复到无锁状态或者释放偏向锁并将锁膨胀为轻量级锁
- 唤醒线程

在这里插入图片描述
JDK6之后偏向锁默认开启。但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:“-XX:BiasedLockingStartupDelay=0”。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过
JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。
1.加锁

  如果成功使用CAS将对象头中的Mark Word替换为指向锁记录的指针,则获得锁,失败则表示其他线程竞争锁,当前线程尝试使用自旋(循环等待)来获取锁。

2.解锁

 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。为了防止继续自旋,一旦升级,将无法降级。

在这里插入图片描述

重量级锁

重量级锁特点:

其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程,进行竞争。

三种锁特点:

首先通过三个小例子来解释三种锁的区别:

  假如家里只有一个碗,当我自己在家时,没有人会和我争碗,这时即为偏向锁状态
  
  当我和我妹都在家吃饭时,如果我妹不是很饿,则她会等我吃完再用我的碗去吃饭,这就是轻量级锁状态

  当我和我妹都很饿的时候,这时候就会去争抢这唯一的一个碗吃饭,这就是重量级锁状态
  1. 重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。JVM采应自旋,来避免线程在面对非常小的synchronized代码块时,仍会被阻塞、唤醒的情况。
  2. 轻量级锁采用CAS操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。
  3. 偏向锁只会在第一次请求时采用CAS操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。
锁粗化

锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁。
范例:

public class Test{
  private static StringBuffer sb = new StringBuffer();
  public static void main(String[] args) {
      sb.append("a");
      sb.append("b");
      sb.append("c");
   }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

public class test {
    private static StringBuilder sb=new StringBuilder();//线程不安全
    public static void main(String[] args){
      synchronized (sb){
          sb.append("a");
          sb.append("b");
          sb.append("c");
      }
    }
}
锁消除

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。

public class Test{
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        sb.append("a").append("b").append("c");
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。

范例:锁消除

public class test {
    public static void main(String[] args){
      StringBuilder sb=new StringBuilder();
      sb.append("a").append("b").append("c");
    }
}

发布了87 篇原创文章 · 获赞 73 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/HL_HLHL/article/details/84619804