线程同步

    技术2022-07-10  103

    多线程同步

    1)为什么需要多线程同步? 一块资源被多个线程同时操作的时候,当没有人任何操作,每个线程不知道什么时候开始执行什么时候结束,所以最终程序的运行结果会跟预想的不同 2)临界资源 临界区 临界资源:同一时刻只能够允许一个线程去访问的资源 临界区:访问临界资源的代码段

    线程同步实际是为了保证线程安全

    synchronized关键字

    Synchornized关键字 防止线程干扰和内存一致性错误,如果一个对象对多个线程是可见的,那么对该对象的所有读/写都可以通过同步的方式来进行

    1)Synchronized关键字提供了锁机制,能够去保证共享资源的互斥访问, 解决数据不一致的问题2)Synchornized的用法同步方法 public synchornized void sync(){ } 同步代码块 private final Object mutex = new Object(); public void sync(){ synchornized(mutex){ ... }

    代码练习: 两个线程,一个顺序输出1 2 3 4 5,一个逆序输出5 4 3 2 1

    public class MyThread { public synchronized void shun(){ for(int i=1;i<=5;i++){ System.out.println(Thread.currentThread().getName()+"顺序输出"+i); } } public void ni(){ synchronized (MyThread.class){ for (int i = 5; i > 0; i--) { System.out.println(Thread.currentThread().getName() + "逆序输出" + i); } } } public static void main(String[] args) { MyThread demo=new MyThread(); new Thread("线程1"){ @Override public void run() { demo.shun(); } }.start(); new Thread("线程2"){ @Override public void run() { demo.ni(); } }.start(); } }

    synchronized关键字是如何实现并发的呢 原子性:确保线程互斥的访问同步代码;

    可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;

    有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;

    synchronized 内置锁 是一种 对象锁(锁的是对象而非引用变量),作用粒度是对象 ,可以用来实现对 临界资源的同步互斥访问 ,是 可重入 的。其可重入最大的作用是避免死锁

    synchronized是如何获得锁的呢 同步代码块: 每一个对象都与一个monitor相关联,一个monitor lock只能被一个线程在同一时间锁获得

    1)如果monitor计数器为0,表示当前monitor lock未被获得,该线程获得之后就会对该计数器 +1-》monitorenter 如果monitor已经被线程锁拥有,则其他线程尝试获取mointor lock,会被陷入阻塞状态,直到moitor计数器变为0,才能够再次去获得monitor lock -》 monitorexit。 以上两个被红框框柱的就是获取锁和释放锁的过程。

    那么同步方法是如何判断的呢 我们没有看到像是同步代码块中的monitorenter和exit的过程,只有一个ACC_SYNCHRONIZED标示符 JVM根据以上标示符去判断该方法是否是同步方法,如果是,执行的线程会先获取monitor lock,获取成功之后会去执行方法体,在方法体执行完之后释放monitor。方法执行期间,其他任何线程都没有办法去获得当前的monitor对象,只能阻塞。

    两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

    Java对象头

    在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。 1.实例数据:存放类的属性数据信息,包括父类的属性信息;

    2.对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

    3.对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

    Synchronized用的锁就是存在Java对象头里的,那么什么是Java对象头呢?Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

    Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。比如锁膨胀就是借助Mark Word的偏向的线程ID。

    MarkWord与锁的升级

    Java对象的状态主要靠Mark Word来标记,主要有5种,大部分与线程有关。这里以64位JVM为例: biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。

    age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

    identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。

    thread:持有偏向锁的线程ID。

    epoch:偏向锁的时间戳。

    ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。

    ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。

    锁的升级

    我们通常说的通过synchronized实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

    1.初期锁对象刚创建时,还没有任何线程来竞争,对象的Mark Word是下图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。

    2.当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如下图第二种情形。

    3.当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线程的栈帧中的锁记录。如下图第三种情形。

    4.如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。

    1)偏向锁 CAS指令:(Compare And Swap)cpu层面的原子性操作指令,该指令存在三个参数,第一参数是目标地址,第二参数是值1,第三参数值2,指令会比较目标存储的值跟值1是否一致,如果一致目标地址会更新为新值,即值2。

    如果一个线程获得了锁,那么锁就会进入偏向模式,锁标识位为01,是否为偏向锁为1,当这次线程再次请求锁的时候,不需要做同步操作,直接省略锁的获取阶段,提高系统的性能。这种场合下可能不存在锁竞争 锁竞争比较激烈的时候,偏向锁获取失败升级为轻量级锁 2)轻量级锁 轻量级锁所适应的线程交替执行同步快的场合 在代码进入同步代码快的时候,如果发现对象锁是无锁状态,在当前线程的栈帧中创建一个lock Record的空间, 存储对象Mark Word的拷贝,JVM使用CAS操作将对象Mark Word更新为指向Lock Record的引用,如果成功, 该线程拥有了这样的对象锁,对象Mark Word的锁标志位设置为00,表明该对象处于轻量级锁的状态;如果失败,锁竞争更加激烈,轻量级锁会升级为重量级锁

    补充:轻量级锁抢锁失败,JVM会使用自旋锁,不断尝试获取锁,jdk1.7默认启用

    3)重量级锁 重量级锁使用会有操作系统的互斥量(MUTEX)和条件和(Conditionvariable)与其关联,在获取锁的过程修改操作系统层面的两个变量

    LockRecord

    在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。

    Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构

    Owner : 初始为NUll,如果有线程获取到monitor锁,Owner会保存线程唯一标识EntryQ:阻塞所有试图获得monitor锁失败的线程RcThis:blocked/Waiting在monitor上的所有线程个数Nest:重入锁的个数HashCode:保存对象的HashcodeCandidate:用来避免不必要的阻塞或者额等待线程唤醒

    monitor

    一种同步机制所有的Java对象天生可以作为monitor每一个对象自实例化之后都带有一把锁,这个锁称之为monitor锁/同步锁

    任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。

    Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

    MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;

    MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

    monitor 的数据结构

    ObjectMonitor() { _header = NULL; _count = 0; // 记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }

    ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时 1.首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;

    2.若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;

    3.若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

    同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。

    Processed: 0.009, SQL: 12