当前位置: 首页 > 后端技术 > Java

并发编程:synchronized

时间:2023-04-01 17:58:42 Java

大家好,我是小黑,一名生活在互联网上的农民工。上一篇文章给大家分享了Java中线程的一些概念和基本使用方法,比如Java中如何启动一个线程,生产者消费者模式等,如果要保证多线程共享的访问并发安全下的数据,操作的原子性,使用synchronized关键字。今天主要和大家聊聊synchronized关键字的用法和底层原理。为什么要使用同步?对于这个问题相信每个人都一定有自己的答案。我还是想在这里说一下。我们看下面的车站售票代码:/***车站同时开两个窗口售票*/publicclassTicketDemo{publicstaticvoidmain(String[]args){TrainStationstation=newTrainStation();//同时启动两个线程卖票newThread(station,"A").start();newThread(station,"B").start();}}类TrainStation实现Runnable{privatevolatileintticket=10;@Overridepublicvoidrun(){while(ticket>0){System.out.println("Thread"+Thread.currentThread().getName()+"Sold"+ticket+"Numberticket");门票=门票-1;}}}上面代码没有考虑线程安全问题,执行这段代码可能会出现如下代码运行结果:可以看出两个线程都买了10号票,这在实际业务场景中是绝对不可能的。(你去坐火车的时候,一个大哥告诉你,你坐了他的座位,叫你走,说你是售票员,生气吗)那么因为这种问题的存在,怎么办我们应该解决它吗?Synchronized就是为了解决这个多线程共享数据的安全问题。synchronized的使用主要有以下三种方式。同步代码块publicstaticvoidmain(String[]args){Stringstr="helloworld";同步(str){System.out.println(str);}}同步实例方法类TrainStation实现Runnable{privatevolatileintticket=100;//关键字直接写在实例方法签名上publicsynchronizedvoidsale(){while(ticket>0){System.out.println("thread"+Thread.currentThread().getName()+"sale"+门票+“号码票”);门票=门票-1;}}@Overridepublicvoidrun(){sale();}}同步静态方法classTrainStationimplementsRunnable{//注意这里ticket变量是声明为static的,因为静态方法只能访问静态变量privatevolatilestaticintticket=100;//也可以直接放在静态方法的签名上已售出”+票+“号码票”);门票=门票-1;}}@Overridepublicvoidrun(){sale();}}字节码语义贯穿程序,我们发现通过synchronized关键字确实可以保证线程安全,那么计算机是怎么保证的呢?这个关键字在幕后究竟做了什么?我们可以看一下java代码编译后的class文件。首先,让我们看一下同步代码块的编译类。字节码文件可以通过javap-vname查看:publicstaticvoidmain(java.lang.String[]);描述符:([Ljava/lang/String;)Vflags:ACC_PUBLIC,ACC_STATICCode:stack=2,locals=4,args_size=10:ldc#2//Stringhelloworld2:astore_13:aload_14:dup5:astore_26:monitorenter//监听输入7:getstatic#3//字段java/lang/System.输出:Ljava/io/PrintStream;10:aload_111:invokevirtual#4//方法java/io/PrintStream.println:(Ljava/lang/String;)V14:aload_215:monitorexit//监控退出16:goto2419:astore_320:aload_221:monitorexit22:aload_323:athrow24:return注意第6行和第15行,这两条指令是在加入synchronized代码块后出现的,monitor是一个对象监控Monitor,monitorenter表示执行这条指令必须先获取到的monitorobject才可以继续执行,而monitorexit表示执行完synchronized代码块后必须退出objectmonitor,即必须释放。所以这个对象监视器就是我们所说的锁,获取锁就是获取这个对象监视器的所有权。接下来我们看一下synchronized修改实例方法时字节码文件是什么样子的。公共同步无效销售();descriptor:()V//方法标识符ACC_PUBLIC表示public修改,ACC_SYNCHRONIZED表示该方法是一个同步方法flags:ACC_PUBLIC,ACC_SYNCHRONIZEDCode:stack=3,locals=1,args_size=10:aload_01:getfield#2//fieldticket:I//省略其他不相关的字节码,可以看到synchronized修饰的实例方法上不会有monitorenter和monitorexit指令,而是直接给这个方法加上一个ACC_SYNCHRONIZED标志。程序运行时,调用sale()方法时,会检查该方法是否有ACC_SYNCHRONIZED访问标志。如果是,则表明该方法是同步方法。这个时候OK线程会先去尝试获取方法对应的monitor。(monitor)对象,如果获取成功,继续执行sale()方法。在执行过程中,任何其他线程都不能再获得使用该方法的监听器的权利。直到方法执行完毕或抛出异常才会释放,其他线程可以重新获取监视器。那么synchronized修饰静态方法的字节码文件是什么呢?公共静态同步无效销售();descriptor:()Vflags:ACC_PUBLIC,ACC_STATIC,ACC_SYNCHRONIZEDCode:stack=3,locals=0,args_size=00:getstatic#2//字段ticket:I//省略其他不相关的词从段代码可以看出synchronized修饰的静态方法和实例方法没有区别。他们都添加了一个ACC_SYNCHRONIZED标志。静态方法只是比实例方法多了一个ACC_STATIC标志来表明该方法是静态的。上面的同步代码块和同步方法都提到了对象监视器的概念,那么三种同步方法中使用的对象监视器分别是哪个对象呢?synchronized代码块的对象监视器就是我们synchronized(str)中的str,也就是我们括号中指定的对象。开发中加入同步代码块的目的是为了让多个线程一次只有一个线程持有monitor,所以这个对象的规范必须是多个线程共享的对象,不能直接new一个对象括号,这样就无法实现互斥,也无法保证安全。同步实例方法的对象监视器是当前实例,也就是this。同步静态方法的对象监视器是当前静态方法所在类的Class对象。我们都知道Java中的每一个类在运行过程中也会有一个对象来表示,这个对象就是这个类的对象。每个班级只有一个。对象锁(monitor)据说线程在进入同步代码块之前需要获得一个对象monitor,也就是对象锁。在我们开始之前,让我们了解一下Java中对象是由什么组成的。这里给大家提个问题,JVM中代码Objectobj=newObject()的内存分配是怎样的?想必了解过JVM知识的同学应该都知道,newObject()会在堆内存中创建一个对象,Objectobj是栈内存中的一个引用,这个引用指向堆中的对象。那么怎么知道堆内存中的对象是由什么组成的呢?这里我将介绍一个叫做JOL(JavaObjectLayout)Java对象布局的工具。可以直接通过maven在项目中引入。引入org.openjdk.joljol-core0.9后可以打印对象的内存分布在代码中。publicstaticvoidmain(String[]args){Objectobj=newObject();//parseInstance解析对象,toPrintable允许输出解析结果System.out.println(ClassLayout.parseInstance(obj).toPrintable());}输出结果如下:java.lang.Objectobjectinternals:OFFSET大小类型描述值04(对象头)01000000(00000001000000000000000000000000)(1)44(对象头)000000(000000000000000000000000000000000)0e对象10000)(0)5f8(11100101000000010000000011111000)(-134217243)124(lossduetonextobjectalignment)Instancesize:16bytesSpacelosses:0bytesinternal+4bytesexternal=4bytestotal从结果可以看出这个objobject主要分为4个部分,每个部分的SIZE=4代表4个字节,前三行是对象头,最后一行是4个字节是为了保证一个对象的大小可以是整数8的倍数。让我们看一下打印出锁定对象的区别。publicstaticvoidmain(String[]args){Objectobj=newObject();同步(obj){System.out.println(ClassLayout.parseInstance(obj).toPrintable());}}java.lang.Objectobjectinternals:OFFSETSIZETYPEDESCRIPTIONVALUE04(objectheader)58f71901(01011000111101110001100100000001)(18478936)44(objectheader)00000000(000000000000000000000000)(0)84(objectheader)e50100f8(11100101000000010000000011111000)(-134217243)124(lossduetonextobjectalignment)Instancesize:16bytesSpacelosses:0bytesinternal+4bytesexternal显然可以看到total=4byte,前8个字节变了,也就是MarkWord变了。所以锁定对象实际上就是改变对象的MarkWord。这8个字节在MarkWord中有不同的含义。为了让这64位能够表示更多的信息,JVM将最后2位设置为标记位。不同标记位下的Markword含义如下:|-----------------------------------------------------------------------------|--------------------||标记字(64位)|状态||--------------------------------------------------------------------------|-------------------||未使用:25|身份哈希码:31|未使用:1|年龄:4|偏向锁:1|锁:2|没有锁||------------------------------------------------------------------------|------------------||线程:54|纪元:2|未使用:1|年龄:4|偏向锁:1|锁:2|偏向锁||------------------------------------------------------------------------------|-------------------||ptr_to_lock_record:62|锁:2|轻量锁||--------------------------------------------------------------------------|------------------||ptr_to_heavyweight_monitor:62|锁:2|重量级锁||----------------------------------------------------------------------------|-------------------|||锁:2|GC标记||-----------------------------------------------------------------------------|--------------------|最后两位Lockflag,不同值代表不同含义biased_locklock状态000无锁状态(NEW)001偏向锁101偏向锁000轻量级锁010重量级锁011GC标志biased_lock标志对象是否启用withbiasedlock,1表示启用偏向锁,0表示不启用。age:4位Java对象年龄。在GC中,如果对象在Survivor区被复制一次,则年龄加1。当一个对象达到设定的阈值时,它被提升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于年龄只有4位,所以最大值为15,这也是-XX:MaxTenuringThreshold选项的最大值为15的原因。identity_hashcode:25位的对象标识符哈希码,采用懒加载技术。调用方法System.identityHashCode()计算并将结果写入对象头。当对象被锁定时,该值将被移动到monitor监视器。thread:持有偏向锁的线程的ID。epoch:偏差时间戳。ptr_to_lock_record:指向栈中锁记录的指针。ptr_to_heavyweight_monitor:指向monitor监视器的指针。由于在锁的升级过程中有无锁、偏向锁、轻量级锁、重量级锁之分,下面我们就来看看这些锁是如何升级的。从上面提到的对象头的结构和上面打印的对象内存分布可以看出,一个新创建的对象,其标志位为00,一个偏向锁标志(biased_lock)也为0,说明该对象是无锁状态。偏向锁偏向锁是指当一段同步代码被同一个线程访问时,没有其他线程的竞争,那么该线程以后访问时会自动获取锁,从而减少获取锁的消耗,提高性能..当一个线程访问同步代码块并获得锁时,线程ID将存储在MarkWord中。当线程进入和退出同步块时,不再通过CAS操作加锁和解锁,而是检测MarkWord中是否存在指向当前线程的偏向锁。轻量级锁的获取和释放依赖多条CAS原子指令,而偏向锁在替换ThreadID时只需要依赖一条CAS原子指令。轻量级锁轻量级锁是指当锁是偏向锁时,有其他线程在竞争,但是锁正在被其他线程访问,那么就会升级为轻量级锁。或者另一种情况是关闭JVM的偏向锁开关,那么锁对象一开始就会被标记为轻量级锁。轻量级锁考虑的是竞争锁对象的线程不多,线程持有锁的时间很短的情况。因为阻塞线程需要CPU从用户态切换到内核态,所以成本比较高。如果在阻塞后不久就释放了锁,这个代价是得不偿失的。因此,这个时候干脆不要阻塞线程,让它自旋即可。这等待锁被释放。进入同步代码时,如果对象锁状态满足升级轻量级锁的条件,虚拟机会在当前要竞争锁的线程的栈帧中开辟一个LockRecord空间,复制MarkWord锁对象到空间中的LockRecord。然后虚拟机使用CAS操作尝试更新对象的MarkWord为指向LockRecord的指针,并将LockRecord中的owner指针指向对象的MarkWord。如果操作成功,则说明当前线程已经获得了锁。如果失败,说明其他线程持有锁,当前线程会尝试使用自旋重新获取。当轻量级锁解锁后,会通过CAS操作将LockRecord替换回对象头。如果成功,则意味着没有竞争。如果失败,则说明当前锁处于竞争状态,该锁将扩展为重量级锁。重量级锁重量级锁是指当一个线程获取到锁时,其他所有等待获取锁的线程都会被阻塞。是依赖于底层操作系统的Mutex实现,Mutex又叫互斥锁。也就是说,重量级锁会将锁从用户态切换到内核态,将线程调度交给操作系统,性能会很低。整个锁的升级过程可以通过下图更加完整的展现出来。需要原图的朋友关注公众号【黑子的学习笔记】后台回复“锁升级”获取。好了,今天就到这里,我们下次再见。

猜你喜欢