java多线程-锁

我们都知道,多个线程并发访问共享变量或者共享资源就回来带来线程安全问题。
于是就可以想到一种保障线程安全的方法--将多个线程的并发访问转换位串行访问,即一个共享数据一次只能被一个线程访问。这个就是锁了。
java平台的锁包括内部锁和显式锁。
内部锁是通过synchronized关键字实现的,显式锁是通过Lock接口的实现类实现的。
锁的作用:保障原子性,可见性,和有序性。
  • 锁是通过互斥保障原子性的,因为锁一次只能被一个线程持有,就保证了临界区代码一次只能被一个线程执行,这使得临界区代码所执行的操作具有不可分割的特性,即具备原子性。
  • 锁的获得隐含着刷新处理器缓存这个动作,即执行临界区代码前,可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中。而锁的释放隐含着冲刷处理器缓存这个动作。使得写线程对共享变量所作的更新能够被推送到该线程执行处理器的高速缓存中,从而对读线程可同步。因此,锁能够保证可见性。
  • 由于锁对可见性的保证,写线程在临界区中对任何一个共享变量所做的更新都对线程可见。由于临界区内的操作具有原子性,因此写线程对共享变量的更新同时对读线程可见
synchronized关键字
  • synchronized可以用来修饰方法或者代码块
  • 作为锁句柄的变量通常用final修饰,这是因为锁句柄变量的值一旦改变,会导致执行同一个同步快的多个线程实际上使用不同的锁,从而导致竞态。
  • 线程对内部锁的申请与释放的动作由java虚拟机负责代为实施。
内部锁的调度:java虚拟机为每个内部锁分配一个入口集,用于记录等待获得相应内部锁的线程。多个线程申请同一个锁的时候,只有一个申请者能申请成为该锁的持有线程,而其他申请者的申请操作会失败。申请失败的线程会被暂停并被存入相应锁的入口集中等待再次申请锁的机会。当线程申请的锁被释放时,该锁的入口集中的一个任意线程会被java虚拟机唤醒,从而得到再次申请锁的机会。
  • 原理
    1.理解Java对象头与Monitor
    在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充,如图:
    java多线程-锁
    文章图片
    image.png
    实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
    对齐填充:由于虚拟机要求对象得起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
    对象头:包含Mark word和class metadata address两部分。
    mark work的存储内容如下
    java多线程-锁
    文章图片
    image.png
    metadata address:即元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
如果对象是数组,那对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组的元数据中无法确定数组的大小。
mark work里面的重量级锁,也就是我们通常说的synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或者监视器锁)的起始地址。每个对象都存在一个monitor与之关联,monitor可以与对象一起创建销毁,也可以当线程试图获取对象锁时自动生成,当一个monitor被某个线程持有后,它便处于锁定状态。
monitor的实现
monitor其实是一种同步工具,被描述为一个对象,他的义务是保证只有一个线程可以访问被保护的数据和代码。
在HotSpot中,monitor是基于c++实现的,由ObjectMonitor实现,结构如下
ObjectMonitor() { _header= NULL; _count= 0; //用来记录该线程获取锁的次数 _waiters= 0, _recursions= 0; _object= NULL; _owner= NULL; //指向持有ObjectMonitor对象的线程 _WaitSet= NULL; //存放处于wait状态的线程队列 _WaitSetLock= 0 ; _Responsible= NULL ; //锁的重入次数 _succ= NULL ; _cxq= NULL ; FreeNext= NULL ; _EntryList= NULL ; //存放处于等待锁block状态的线程队列 _SpinFreq= 0 ; _SpinClock= 0 ; OwnerIsThread = 0 ; }

当多个线程同时访问一段同步代码的时候,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域,并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1,即获得对象锁
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取monitor.
以下是获取锁的过程:

java多线程-锁
文章图片
image.png
monitor对象存在于每个java对象的对象头中,synchronized锁便是通过这种方式获取锁的,这也是为什么java中任意对象可以作为锁的原因。
同时也是notify/notifyAll/wait等方法存在于顶级对象object中的原因,在使用这几个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll/wait这几个方法依赖于monitor对象,而要获取monitor,就必须通过synchronized关键字,这也就是notify/notifyAll/wait方法必须在synchronized代码块或者synchronized方法调用的原因了。
synchronized的实现原理
public class SynchronizedDemo { //同步方法 public synchronized void doSth(){ System.out.println("Hello World"); }//同步代码块 public void doSth1(){ synchronized (SynchronizedDemo.class){ System.out.println("Hello World"); } } }

看一下上面代码的字节码,如下
public synchronized void doSth(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic#2// Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc#3// String Hello World 5: invokevirtual #4// Method java/io/PrintStream.println:(Ljava/lang/String; )V 8: return public void doSth1(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: ldc#5// class com/hollis/SynchronizedTest 2: dup 3: astore_1 4: monitorenter 5: getstatic#2// Field java/lang/System.out:Ljava/io/PrintStream; 8: ldc#3// String Hello World 10: invokevirtual #4// Method java/io/PrintStream.println:(Ljava/lang/String; )V 13: aload_1 14: monitorexit 15: goto23 18: astore_2 19: aload_1 20: monitorexit 21: aload_2 22: athrow 23: return

通过反编译后代码可以看出,对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。对于同步代码块,JVM采用monitorenter,monitorexit两个指令来实现同步。
当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器,然后再执行方法,方法执行后释放监视器锁。如果在方法执行过程中,发生异常,并且方法内部没有处理该异常,那么异常被抛到方法外面之前监视器锁会自动释放。
同步代码块使用monitorenter和monitorexit两个指令实现。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。每个对象维护着一个记录着被锁次数得计数器。未被锁定得对象得该计数器为0,当一个线程获得锁后,该计数器自增变为1,当一个线程再次获得该对象得锁时,计数器再次自增。当线程释放锁时,计数器自减。当计数器为0的时候,锁被释放。
【java多线程-锁】synchronized为什么被叫重量级锁?
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到核心态,因此状态转换需要花费很多的处理器时间,对于代码简单的同步快状态转换消耗的时间有可能比用户代码执行的时间还要长,所以说是一个重量级的操作。

    推荐阅读