scronized是同步关键字,在某些多线程的情况下,如果不进行同步数据会导致数据共享不安全(不同线程拿到同样对象和数组,数据不更新),而sycronized关键字可以保证代码数据同步。
一、sycronized的使用方式
sycronized主要有三种使用形式:
1.修饰普通方法,锁的对象是当前的实例对象
2.修饰静态同步方法,锁的对象是当前的类的class字节码对象
3.修饰同步代码块,所的对象sycronize后面{}里配置的对象,可以是是某个类,也可以是某个类的.class对象
二、syncronized的特性
1.原子性
2.可见性
3.有序性
4.可重入性
1.原子性:原子性是指在一次或多次操作中,要么所有操作都执行,要么都不执行。
2.可见性:可见性是指一个线程对共享变量进行了修改,那么另一个线程可以立即得到这个线程修改过后的数值
syncronized可见性是通过内存屏障来实现的,按可见性,内存屏障可划分:
Load屏障:执行refresh操作,把其他处理器的高速缓冲、主内存,加载数据到自己的高速缓冲、主内存中。
Store屏障:执行flush操作,自己处理器更新变量的值,刷新到高速缓冲、主内存去。
获取锁时,会清空当前线程工作内存中共享变量的副本值,重新从主内存中获取变量最新的值;
释放锁时,会将工作内存的值重新刷新回主内存;
int a = 0;
synchronize (this){ //monitorenter
// Load内存屏障
int b = a; // 读,通过load内存屏障,强制执行refresh,保证读到最新的
a = 10; // 写,释放锁时会通过Store,强制flush到高速缓存或主内存
} //monitorexit
//Store内存屏障
3.有序性:
有序性是指代码在执行的过程中会按一定的顺序 ,java会进行优化编译,导致过程发生了变化。
例如,instance = new Singleton()实例化对象的语句分为三步:
1.分配对象的内存地址
2.初始化对象
3.设置实例对象指向刚分配的地址
上面的这些步骤,按正确的来说是1-》2-》3,但是有可能被打乱顺序,变成1-》3-》2,这时候会报错(记得是空指针异常)。
synchronized的可序性依然是靠内存屏障来实现的。
按照有序性,内存屏障可分为:
Acquie屏障:在load屏障之后,防止同步代码块中的读指令和同步代码块外的读指令操作发生指令重排。
Release屏障:禁止写操作,和外面的读写操作发生指令重排。
在monitorrenter指令和load屏障之后,会加一个Acquie屏障,防止代码块内和代码快外发生指令重排,在monitorexit指令之前会加一个Release屏障,禁止写操作,防止和代码块外的代码发生指令重排。
int a = 0;
synchronize (this){ //monitorenter
// Load内存屏障
// Acquire屏障,禁止代码块内部的读,和外面的读写发生指令重排
int b = a;
a = 10; //注意:内部还是会发生指令重排
// Release屏障,禁止写,和外面的读写发生指令重排
} //monitorexit
//Store内存屏障
关于monitorz指令介绍在文章末
4.可重入特性
可重入特性指的是一个线程,可重复多次执行synchronized,重复获取同一把锁。
例子:
public class RenentrantDemo {
// 锁对象
private static Object obj = new Object();
public static void main(String[] args) {
// 自定义Runnable对象
Runnable runnable = () -> {
// 使用嵌套的同步代码块
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "第一次获取锁资源...");
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "第二次获取锁资源...");
synchronized (obj) {
System.out.println(Thread.currentThread().getName() + "第三次获取锁资源...");
}
}
}
};
new Thread(runnable, "t1").start();
}
}
运行结果:
t1第一次获取锁资源...
t1第二次获取锁资源...
t1第三次获取锁资源...
synchonized的使用及通过反汇编分析其原理:
public class SynchronizedDemo01 {
// 锁对象
private static Object obj = new Object();
public static void main(String[] args) {
synchronized (obj) {
System.out.println("execute main()...");
}
}
}
使用javap -p -v ./SynchronizedDemo01.class命令对字节码进行反汇编,查看字节码指令:
monitorenter指令:
在官网的说明中,每个对象都会和一个监视器对象monitor关联,监视器被占用(其他线程使用时),会被锁住,其他线程无法获取该对象的monitor,当JVM执行某个线程中的某个方法的内部monitorenter时,它会尝试获取该对象的监视器monitor对象。过程大致如下:
1.若monitor进入数为0,线程可以进入monitor,并将monitor的进入数更新为1,当前线程成为monitor的拥有者(owner)。
2.若该线程已拥有monitor则可以重复进入monitor,此时monitor数不断+1
3.若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有线程就会阻塞,直到monitor的计数器变为0才会重新获取。
monitorexit指令:
官网对monitorexit指令的介绍说,只有拥有monitor的所有权的线程,才能执行monitor exit;执行monitor exit指令时,计数器会减1,直到monitor的进入数减为0,当前线程退出。
为什么上面的字节码文件会出现两个monitor exit?
其实第二个monitorexit指令,是在程序发生异常时候用到的,也就说明synchronized在发生异常时,会自动解锁。
ObjectMonitor对象监视器结构如下:
ObjectMonitor() {
_header = NULL; //锁对象的原始对象头
_count = 0; //抢占当前锁的线程数量
_waiters = 0, //调用wait方法后等待的线程数量
_recursions = 0; //记录锁重入次数
_object = NULL;
_owner = NULL; //指向持有ObjectMonitor的线程
_WaitSet = NULL; //处于wait状态的线程队列,等待被唤醒
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //等待锁的线程队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
2.修饰普通方法:
public class SynchronizedDemo02 {
public static void main(String[] args) {
}
// 修饰普通方法
public synchronized void add() {
System.out.println("add...");
}
}
使用javap -p -v .\SynchronizedDemo02.class命令对字节码进行反汇编,查看字节码指令:
如上图,我们可以看到同步方法在反汇编后,不再是通过插入monitorentry和monitorexit指令实现,而是会增加 ACC_SYNCHRONIZED 标识隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
3.修饰静态方法:
public class SynchronizedDemo03 {
public static void main(String[] args) {
add();
}
// 修饰静态方法
public synchronized static void add() {
System.out.println("add...");
}
}
锁对象存放在哪里:
在文章末有关于monitor的介绍,而锁对象就存放在里面,monitor有在对象头中,对象头包括mark word标记子,类型指针,数组长度(只有数组才会有),对象包括对象头,实例数据和对齐填充。在mark word 有一块区域主要存放关于锁的信息。
存在锁对象的对象头的MarkWord标记字中。如下图:
synchronized与lock的区别?
区别 | synchronized | lock |
1 | 关键字 | 接口 |
2 | 自动释放锁 | 必须手动调用unlock()方法释放锁 |
3 | 不能知道线程是否拿到锁 | 可以知道线程是否拿到锁 |
4 | 能锁住方法和代码块 | 只能锁住代码块 |
5 | 读、写操作都阻塞 | 可以使用读锁,提高多线程读效率; |
6 | 非公平锁 | 通过构造方法可指定是公平锁/非公平锁 |
总结:
当synchronized关键字用在代码块时,会先获取monitorz标记,只有获取monitorz关键字时,才可以对代码块中的对象进行操作,若该monitorz被其他线程持有时,会阻塞要获取该代码块的monitorz的所有线程,当该线程执行完毕代码块时,计数器变成零,才有机会获取该monitorz。
而monitorz存在于对象头中,对象分为对象头,实例数据,和对其填充,对象头又分为monitor、类型指针和数组大小(只有数组才会有,因为jvm会根据元数据获取对象大小,而数据不行,所以要额外标记。)。
而monitorzenter后的load()屏障(将执行refesh操作,将其他处理器的数据加载到此处理器中),和store()屏障,将自己处理器中的数据加载到其他处理器,其中这两个屏障之间还有两道屏障,分别是Aquice(字母错了)屏障和release屏障,禁止代码块外进行读写操作,aquice读操作,release写操作,防止指令重排,保证synchronize的原子性和有序性。
而当synchronized修饰普通方法和静态方法时,通过字节码在头部中添加ACC_SYNCHRONIZED标识,程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象;
在执行代码块时,synchronized锁的对象就算代码块中的对象,而执行静态方法时,锁的对象是当前类Class的字节码对象,而执行普通方法时,锁的对象就是当前对象的this。
使用synchronized修饰实例对象时,如果一个线程正在访问实例对象的一个synchronizd的方法,其他线程不仅不能访问该synchronizd方法,也不能访问其他syncharonizd的方法,毕竟,一个实例对象只有一个monitor,但可以范文非synchronizd方法。
线程A访问线程实例对象的非static synchronized方法时,线程B同时可以访问实例对象的static synchronized方法,因为前者获取的是实例对象的监视锁,后者获取的是类对象的监视锁,并不满足互斥关系。
了解monitorz之前外面需要了解对象内存布局
对象在内存中的存储可以分为三部分,分别是对象头(Header),实例数据(Instance Data)和对齐填充(Padding),其中对象头又分为三部分组成。
对象头:
由mark word、类型指针、数组长度(只有数组对象才有)三部分组成
1)Mark Word:
Mark Word存储对象自身运行时的数据,如哈希码(hashcode)、GC分代年龄、同步锁信息、偏向锁标识等等。Mark Word在32位JVM中的长度是32bit,在64位jvm中是64bit。
通常我们使用64位的JVM,在64位JVM,mark word分布如下
GC为垃圾回收
2.类型指针
类型指针指向对象的元数据,虚拟机通过指针确定该对象为哪个类型的实例。Java的类数据保存在方法区中。
3.数组长度
如果对象是一个数组,那么对象头还需要额外的空间来存储数组长度。如果不是数组,虚拟机会根据对象的元数据信息来确定对象的大小,而无法确定数组的大小,所以需要数组长度来存储长度信息。
实例数据:
实例数据部分存放类的信息,包括父类的属性信息
通过示例说明每个区域具体存放哪些内容:
class Student {
private String name;
public Student(String name) {
this.name = name;
}
}
public class Demo {
public static void main(String[] args) {
Student studentA = new Student("zhangsan");
Student studentB = new Student("lisi");
}
}
JVM结构如下:
对齐填充:
虚拟机要求对象起始地址必须是8字节的整数倍,所以后面几个字节把对象大小补充至8字节的整数倍,没有说明特别的功能,对齐字节必须存在,仅仅是为了对齐字节。
为什么必需是8字节?
详解可参考:为什么JVM要用到压缩指针?Java对象要求8字节的整数倍? - 知乎 (zhihu.com)
使用JOL工具分析对象内存布局
接下来我们使用JOL(Java Object Layout)工具,它是一个用来分析JVM中Object布局的小工具。包括Object在内存中的占用情况,实例对象的引用情况等等。
直接在maven工程中加入对应的依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
通过JOL查看new Object()的对象布局信息:
public class JOLDemo {
public static void main(String[] args) {
Object obj = new Object();
System.out.println("十进制hashCode = " + obj.hashCode());
System.out.println("十六进制hashCode = " + Integer.toHexString(obj.hashCode()));
System.out.println("二进制hashCode = " + Integer.toBinaryString(obj.hashCode()));
String str = ClassLayout.parseInstance(obj).toPrintable();
System.out.println(str);
}
}
十进制hashCode = 1956725890
十六进制hashCode = 74a14482
二进制hashCode = 1110100101000010100010010000010
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 82 44 a1 (00000001 10000010 01000100 10100001) (-1589345791)
4 4 (object header) 74 00 00 00 (01110100 00000000 00000000 00000000) (116)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
解释下各个字段的含义:
- OFFSET是偏移量,也就是到这个字段位置所占用的字节数;
- SIZE是后面类型的大小;
- TYPE是Class中定义的类型;
- DESCRIPTION是类型的描述;
- VALUE是TYPE在内存中的值;
从上图可以看出Object obj = new Object();在内存中占16个字节,注意最后面的(loss due to the next object alignment)其实就是对齐填充的字节数,这里由于Object obj = new Object();没有实例数据,对象头总共占用了12个字节(默认开启了指针压缩-XX:+UseCompressedOops),由于虚拟机要求对象起始地址必须是8字节的整数倍,所以还需要对齐填充4个字节,达到2倍的8bit。