基本使用之八锁问题
①. 标准访问有ab两个线程,请问先打印邮件还是短信
②. sendEmail方法暂停3秒钟,请问先打印邮件还是短信
③. 新增一个普通的hello方法,请问先打印邮件还是hello
④. 有两部手机,请问先打印邮件还是短信
⑤. 两个静态同步方法,同1部手机,请问先打印邮件还是短信
⑥. 两个静态同步方法, 2部手机,请问先打印邮件还是短信
⑦. 1个静态同步方法,1个普通同步方法,同1部手机,请问先打印邮件还是短信
⑧. 1个静态同步方法,1个普通同步方法,2部手机,请问先打印邮件还是短信
class Phone{ //资源类 public static synchronized void sendEmail() { //暂停几秒钟线程 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("-------sendEmail"); } public synchronized void sendSMS() { System.out.println("-------sendSMS"); } public void hello() { System.out.println("-------hello"); } } public class Lock8Demo{ public static void main(String[] args){//一切程序的入口,主线程 Phone phone = new Phone();//资源类1 Phone phone2 = new Phone();//资源类2 new Thread(() -> { phone.sendEmail(); },"a").start(); //暂停毫秒 try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(() -> { //phone.sendSMS(); //phone.hello(); phone2.sendSMS(); },"b").start(); } } /** * * ============================================ * 1-2 * * 一个对象里面如果有多个synchronized方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法了, * * 其它的线程都只能等待,换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronized方法 * * 锁的是当前对象this,被锁定后,其它的线程都不能进入到当前对象的其它的synchronized方法 * * 3-4 * * 加个普通方法后发现和同步锁无关 * * 换成两个对象后,不是同一把锁了,情况立刻变化。 * * 5-6 都换成静态同步方法后,情况又变化 * 三种 synchronized 锁的内容有一些差别: * 对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部部手机,所有的普通同步方法用的都是同一把锁——实例对象本身, * 对于静态同步方法,锁的是当前类的Class对象,如Phone.class唯一的一个模板 * 对于同步方法块,锁的是 synchronized 括号内的对象 * * 7-8 * 当一个线程试图访问同步代码时它首先必须得到锁,退出或抛出异常时必须释放锁。 * * * * 所有的普通同步方法用的都是同一把锁——实例对象本身,就是new出来的具体实例对象本身,本类this * * 也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁。 * * * * 所有的静态同步方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板Class * * 具体实例对象this和唯一模板Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的 * * 但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁。 **/
javap
javap到底是做什么的
通过反编译生成的字节码文件,我们可以深入的了解java代码的工作机制。但是,自己分析类文件结构太麻烦了!除了使用第三方的jclasslib工具之外,oracle官方也提供了工具:javap。
javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区 (字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息。
通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。
一般常用的是-v -l -c -p四个选项。
javap -l :会输出行号和本地变量表信息;
javap -c :会对当前class字节码进行反编译生成汇编代码;
javap -v: class字节码文件中除了包-c参数包含的内容外,还会输出行号、局部变量表信息、常量池等信息;
java -p:显示所有类和成员
使用javap查看synchronized
作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁
public class Main { Object object = new Object(); public void m1(){ synchronized (object){ System.out.println("synchronized ----- demo"); } } public static void main(String[] args) { } }
使用 javap -c .\Main.class > Main_c.txt 命令反编译
Compiled from "Main.java" public class com.test.Main { java.lang.Object object; public com.test.Main(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: new #2 // class java/lang/Object 8: dup 9: invokespecial #1 // Method java/lang/Object."<init>":()V 12: putfield #3 // Field object:Ljava/lang/Object; 15: return public void m1(); Code: 0: aload_0 // aload_0 表示对this的操作,在static 方法中,aload_0表示对方法的第一参数的操作 1: getfield #3 // Field object:Ljava/lang/Object; getfield指令表示获取指定类的实例域,并将其值压入栈顶 4: dup // dup指令可以复制操作数栈栈顶的一个字,再将这个字压入栈。也就是对栈顶的内容做了个备份,此时操作数栈上有连续相同的两个对象地址。 5: astore_1 // astore_1指令,JVM从操作数栈顶部弹出一个引用类型或者returnAddress类型值,然后将该值存入由索引1指定的局部变量中,即将引用类型或者returnAddress类型值存入局部变量1。 6: monitorenter // 线程执行monitorenter指令时尝试获取monitor的所有权 7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; 获取指定类的静态域,并将其值压入栈顶 10: ldc #5 // String synchronized ----- demo ldc指令将int、float、或者一个类、方法类型或方法句柄的符号引用、还可能是String型常量值从常量池中推送至栈顶。ldc指令可以加载String、方法类型或方法句柄的符号引用,但是如果要加载String、方法类型或方法句柄的符号引用,则会在类连接过程中重写ldc字节码指令为虚拟机内部使用的字节码指令_fast_aldc。 12: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String; 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。 15: aload_1 // 将第二个引用类型本地变量推送至栈顶 16: monitorexit // 释放锁 17: goto 25 // 目前主要的无条件跳转指令为 goto 20: astore_2 21: aload_1 22: monitorexit //再次释放锁,如果方法出现异常,就会导致上面一个monitorexit无法执行 23: aload_2 24: athrow 25: return Exception table: from to target type 7 17 20 any 20 23 20 any public static void main(java.lang.String[]); Code: 0: return }
线程执行monitorenter指令时尝试获取monitor的所有权,通过monitorexit释放所有权
注:目前有两个monitorexit,第一个是方法执行完正常释放锁,另一个是如果方法出现异常系统级别的释放锁。
作用于代码块,对括号里配置的对象加锁
public class Main { public synchronized void m1() { System.out.println("synchronized ----- demo"); } public static void main(String[] args) { } }
使用 javap -v -p .\Main.class > Main_vp.txt 命令
调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程会将先持有monitor然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放minotor
作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁
public class Main { public static synchronized void m1() { System.out.println("synchronized ----- demo"); } public static void main(String[] args) { } }
使用 javap -v -p .\Main.class > Main_vp.txt 命令
ACC_STATIC、ACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法
深入源码看synchronized
任何一个对象都可以成为一个锁,在HotSpot虚拟机中,monitor采用ObjectMonitor实现
HotSpot虚拟机源码下载
https://hg.openjdk.org/jdk8u/jdk8u60/hotspot/
涉及文件:ObjectMonitor.java — ObjectMonitor.cpp — ObjectMonitor.hpp
ObjectMonitor.hpp(底层源码解析)
关键的属性:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数
具体执行流程:
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
原理剖析
上述大致了解了synchronized的执行流程,那么真如上述这么简单吗?答案是否定的。上述讲解了synchronized重量级锁的执行流程,这种方式是很影响性能的,对于jvm而言却做了大量的优化。
java线程阻塞的代价
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。
在学习什么是无锁、偏向锁、轻量锁、重量锁,以及怎么进行锁升级的前提,需要补充在JVM中对象基本组成的知识
对象组成
Java 虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由 对象头 + 实例数据 + 对齐填充数据 三个部分共同组成
- 对象头:包括了堆对象的类型、GC状态、锁状态和哈希码等基本信息,Java 对象和 JVM 内部对象的对象头格式一致
- 实例数据:主要是存放对象的类自身属性信息以及父类的属性信息,如果一个类没有字段属性,就不需要实例数据域
- 对齐填充数据:虚拟机规范要求每个对象所占内存字节数必须是 8N,对齐填充的存在就是为了满足规范要求
这里我们主要讲对象头,对象头的数据总共包含了 3 个部分,以下是各个部分的用途:
1、Mark Word:包含一系列的标识,例如锁的标记、对象年龄等。在32位系统占4字节,在64位系统中占8字节
2、Class Pointer:指向对象所属的 Class 在方法区的内存指针,通常在32位系统占4字节,在64位系统中占8字节,64位 JVM 在 1.6 版本后默认开启了压缩指针,那就占用4个字节
3、Length:如果对象是数组,还需要一个保存数组长度的空间,占 4 个字节
其中 Mark Word
64位是对象头中非常关键的一部分,其在 JVM 中结构如下图所示:
在不同锁状态下,这64位存储的东西都不一样
hashcode
哈希code很容易理解,将对象存储到一些map或者set里时,都需要hashcode来确认插入位置。
但markword里的hashcode,和我们平时经常覆写的hashCode()还是有区别的。
markword中的hashcode是哪个方法生成的?
很多人误以为,markword中的hashcode是由我们经常覆写的hashcode()方法生成的。
实际上, markword中的hashcode只由底层 JDK C++ 源码计算得到(java侧调用方法为 System.identityHashCode() ), 生成后固化到markword中,
如果你覆写了hashcode()方法, 那么每次都会重新调用hashCode()
方法重新计算哈希值。
根本原因是因为你覆写hashcode()之后,该方法中很可能会利用被修改的成员来计算哈希值,所以jvm不敢将其存储到markword中。
因此,如果覆写了hashcode()方法,对象头中就不会生成hashcode,而是每次通过hashcode()方法调用
markword中的hashcode是什么时候生成?
很容易误以为会是对象一创建就生成了。
实际上,是采用了延迟加载技术,只有在用到的时候才生成。
毕竟有可能对象创建出来使用时,并不需要做哈希的操作。
hashcode在其他锁状态中去哪了?
这个问题会在后面锁升级的3个阶段中,解释hashcode的去向
gc分代年龄(4bit)
在jvm垃圾收集机制中, 决定年轻代什么时候进入老年代的根据之一, 就是确认他的分代年龄是否达到阈值,分代年龄只有4bit可以看出,最大值只能是15。因此我们设置的进入老年代年龄阈值 -XX:MaxTenuringThreshold 最大只能设置15。
cms_free
在无锁和偏向锁中,还可以看到有1bit的cms_free。
实际上就是只有CMS收集器用到的。但最新java11中更多用的是G1收集器了,这一位相当于不怎么常用,因此提到的也非常少。
从上述可以看出, 只有锁状态标记位、 hashcode、 分代年龄、cms_free是必有的, 但是从markword最初的示意图来看, hashcode、 分代年龄、cms_free似乎并非一直存在,那么他们去哪了呢?会在后面的锁升级过程进行详细解释。
无锁001
处于无锁状态的条件或者时机是什么?
无锁状态用于对象刚创建,且还未进入过同步代码块的时候
这一点很重要, 意味着如果你没有同步代码块或者同步方法, 那么将是无锁状态。
无锁验证
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.9</version> </dependency>
Object obj = new Object(); System.out.println(ClassLayout.parseInstance(obj).toPrintable(obj));
这时候obj的锁状态标志位001代表无锁
hashcode
上述例子中对象头markword中并没有显示hashcode值,而是一堆0,需要调用hashcode()显示hashcode
jvm默认开启偏向锁,且有4s延迟
这其实是jvm后面加入的一种优化, 对每个新对象,预置了一个**“可偏向状态”,也叫做匿名偏向状态**,是对象初始化中,JVM 帮我们做的。
注意此时 markword中高位是不存在ThreadID的, 都是0, 说明此时并没有线程偏向发生,因此也可以理解成是无锁。
好处在于后续做偏向锁加锁时,无需再去改动偏向锁标记位,只需要对线程id做cas即可。