syn锁深入了解

目录

前言:

一:syn主要实现方式和基本原理:

1.1实现原理

1.2syn特征:

二. synchronized底层存储

2.1 对象结构

2.2 对象头的组成

2.2.2 class pointer

2.3 Monitor监视器锁

2.3.1 monitor介绍

2.3.2 Java对象Object的Monitor机制


前言:

上次多线程锁使用写到了syn和lock的基本使用

以及一篇线程的代码测试:线程池、可重入锁、读写锁、信号量、循环栅栏,编码测试

本次,再多了解一些syn锁,(源码分析,还没看完,下次补充)

syn锁:就是解决某个代码想要达到同步的效果,某一个线程在执行和过程中,不希望这个代码收到别的线程的影响;

例如:一个公共类,负责生成整体的流水号,简单理解就是,要保证多线程情况下,生成的流水号不要有重复的,当然实现方式有很多,这里就不多写了,本文主要在学习一些syn锁

syn锁特点

独占锁

可重入锁

非公平锁

syn1.6之前是重量级锁,之后进行优化,多线程情况下锁升级:无锁、偏向锁、轻量级锁、重量级锁,锁粗化、锁消除等手段,这也算是后来同步的map实现锁方式,更改为syn+CAS实现的一部分原因;

锁升级和参考实现参考 syn和lock的基本使用

一:syn主要实现方式和基本原理:

1.1实现原理

1.修饰代码块

使用字节码指令,进行获取monitor和释放

通过使用monitorenter和monitorexit指令实现的,当线程执行monitorenter的时候就会尝试获取monitor的所有权,如果当前对象的monitor的进入计数器为0,就可以获取到这个对象锁,如果当前对象已经拥有了这个monitor的持有全,那么就可以重入这个monitor,重入的时候计数器也会加一。如果其他线程已经拥有了monitor的持有权,那么他就会阻塞,直到正在执行的线程执行完毕,即monitorexit指令执行,释放掉对象锁,并且把计数器设置为0

2.修饰方法

借助方法头中是否有同步标识,没有的话,进行获取monitor和释放

通过指令monitorenter和monitorexit来完成,而是通过ACC_SYNCHRONIZED标识符,放在常量池中。当方法调用的时候会检查方法的ACC_SYNCHRONIZED标识符是否已经被设置了,如果被设置了,执行的线程会先获取monitor,获取成功了才能执行方法体,方法执行完毕之后再释放monitor。在方法执行期间,任何其他线程都无法再获得同这个monitor对象。 其实和代码块本质上么有区别,只是方法同步是一种隐性的方式实现的,无需字节码来完成。
 

代码块是隐式的使用monitor,方法是显示的使用字节码指令指令进行操作monitoe,

关于ACC_SYNCHRONIZED 、monitorenter、monitorexit指令,可以看下面的反编译代码

参看网上截图

public class SynchronizedTest {
    public void get(){
        synchronized (this){        // 这个是同步代码块
            System.out.println("你好呀");
        }
    }
    public synchronized void f(){    //这个是同步方法
        System.out.println("Hello world");
    }

    public static void main(String[] args) {

    }

}

可以通过javap -verbose SynchronizedTest 对代码进行反编译,如下:

 monitorenter:代表 监视器入口,获取锁;
monitorexit:代表监视器出口,释放锁;
monitorexit:第二次monitorexit,代表 发生异常,释放锁;

 ACC_SYNCHRONIZED访问标志:当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
 

1.2syn特征:

1.原子性:单一线程持有

2.可见性:内存强制刷新

        引用一个网上描述:

 线程解锁前,必须把共享变量的最新值刷新到主内存中。
线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存
中重新读取最新的值。
volatile 的可见性都是通过内存屏障(Memnory Barrier)来实现的。
synchronized 靠操作系统内核的Mutex Lock(互斥锁)实现,相当于 JMM 中的 lock、unlock。退出代码块时刷新变量到主内存。

3.有序性:

本线程内都是同步有序的,本线程以外无序(非公平锁)

4.重入性:

通过判断头对象中线程对象是否持有锁

synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

public class A {
    public synchronized void doA(){
        System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
    }
}

public class RetryTest extends A {
    public static void main(String[] args) {
        RetryTest retryTest = new RetryTest();
        retryTest.doA();
    }

    public synchronized void doA(){
        System.out.println("子类方法:RetryTest.doA() ThreadId:" + Thread.currentThread().getId());
        doB();
    }

    private synchronized void doB(){
        super.doA();
        System.out.println("子类方法:RetryTest.doB() ThreadId:" + Thread.currentThread().getId());
    }
}

输出:

二. synchronized底层存储

synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。

2.1 对象结构

HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、
实例数据(Instance Data)和对齐填充(Padding)。

实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
对象头:HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位的虚拟机中分别为32位和64位。官方称为Mark Word。另一部分用于存储指向对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分存储数组长度。
 

 由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。

2.2 对象头的组成

先简单介绍下对象头的形式,JVM中对象头的方式有以下两种(以32位JVM为例):

普通对象:

 数组对象:

 2.2.1 Mark Word
这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:

其中各部分的含义如下:

  • lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。

  •  bias_lock:对象是否启动偏向锁标记,只占1个二进制位。为1时表示对象启动偏向锁,为0时表示对象没有偏向锁。
  • age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
  • identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
  • thread:持有偏向锁的线程ID。
  • epoch:偏向时间戳。
  • ptr_to_lock_record:指向栈中锁记录的指针。
  • ptr_to_heavyweight_monitor:指向monitor对象(也称为管程或监视器锁)的起始地址,每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor对象可以与对象一起创建销毁或当前线程试图获取对象锁时自动生,但当一个monitor被某个线程持有后,它便处于锁定状态。
     

2.2.2 class pointer

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。

如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

  • 每个Class的属性指针(即静态变量)
  • 每个对象的属性指针(即对象变量)
  • 普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。

2.2.3 array length
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。

2.3 Monitor监视器锁

2.3.1 monitor介绍

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
 

2.3.2 Java对象Object的Monitor机制

ava虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制。

在java.lang.Object类中有如下代码:

public class Object {
…
private transient int shadow$monitor;
public final native void notify();
public final native void notifyAll();
public final native void wait() throws InterruptedException;
public final void wait(long millis) throws InterruptedException {
wait(millis, 0);
}
public final native void wait(long millis, int nanos) throws InterruptedException;
…
}
1

结合上图来分析Object的Monitor机制。

Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。

当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。

再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set队列中被唤醒的线程和entry-set队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。