当一个资源有可能被多个线程同时访问并修改的话,此时则需要用到锁,而Java中使用锁的方式之一就是使用Synchronized
一、锁的现象
首先可以用如下模板,逐步证明锁的现象
package com.quattro.learnhutool.test;
import java.util.concurrent.TimeUnit;
/**
* @author Kernel Move
*/
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func1();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s.func2();
}, "t2").start();
}
}
class Sync {
public void func1() {
try {
System.out.println(" == func1 sleep == ");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func1...");
System.out.println("thread1 printTime:" + System.currentTimeMillis());
}
public void func2() {
System.out.println("func2...");
System.out.println("thread2 printTime:" + System.currentTimeMillis());
}
}
synchronized修饰方法
1:修饰非static方法
当Synchronized修饰非static方法的时候,锁定的是调用者
package com.quattro.learnhutool.test;
import java.util.concurrent.TimeUnit;
/**
* @author Kernel Move
*/
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func1();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s.func2();
}, "t2").start();
}
}
class Sync {
public synchronized void func1() {
try {
System.out.println(" == func1 sleep == ");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func1...");
System.out.println("thread1 printTime:" + System.currentTimeMillis());
}
public synchronized void func2() {
System.out.println("func2...");
System.out.println("thread2 printTime:" + System.currentTimeMillis());
}
}
因为调用者都是Sync类的实例化对象s,因此s对象会被锁住,t1获取了s对象,并占用,此时t2无法获取, 所以需要等待t1执行完成释放资源后,t2才能执行。这个锁并不是锁在方法上,见示例:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
Sync s2 = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func1();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s2.func1();
}, "t2").start();
}
}
class Sync {
public synchronized void func1() {
try {
System.out.println(" == func1 sleep == ");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func1...");
System.out.println("thread1 printTime:" + System.currentTimeMillis());
}
public synchronized void func2() {
System.out.println("func2...");
System.out.println("thread2 printTime:" + System.currentTimeMillis());
}
}
并没有发生资源的争抢,因此可见, synchronized修饰非static类时候,锁定的是调用者。
2:修饰static方法
当Synchronized定义在static方法上时,锁定的是static方法所在的类
示例1:
public class SyncDemo {
public static void main(String[] args) {
/**
* 静态方法调用 都是使用【类.方法】的方式 这里这是为了测试
* 只是视觉效果 根据static的原理 调用的是同一份
*/
Sync s = new Sync();
Sync s2 = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func1();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s2.func1();
}, "t2").start();
}
}
class Sync {
public static synchronized void func1() {
try {
System.out.println(" == func1 sleep == ");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func1...");
System.out.println("thread1 printTime:" + System.currentTimeMillis());
}
public static synchronized void func2() {
System.out.println("func2...");
System.out.println("thread2 printTime:" + System.currentTimeMillis());
}
}
此时1进程运行3秒后,资源释放,2进程才能获取到资源
示例2:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
Sync.func1();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
Sync.func2();
}, "t2").start();
}
}
class Sync {
public static synchronized void func1() {
try {
System.out.println(" == func1 sleep == ");
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func1...");
System.out.println("thread1 printTime:" + System.currentTimeMillis());
}
public static synchronized void func2() {
System.out.println("func2...");
System.out.println("thread2 printTime:" + System.currentTimeMillis());
}
}
此时在t1释放资源后,t2获取资源执行
综上,修饰static方法时候,锁定的是类
synchronized修饰代码块
当Synchronized声明在代码块的时候,锁定的是传入的对象
示例1:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func();
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s.func();
}, "t2").start();
}
}
class Sync {
public void func() {
synchronized (Sync.class) {
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func ...");
System.out.println(System.currentTimeMillis());
}
}
}
锁定的是传入的“Sync.class”,因为是同一个,所以会产生资源争抢,在t1释放资源后,t2才能获得资源执行
示例2:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
Sync s2 = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
s.func(s);
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
s2.func(s2);
}, "t2").start();
}
}
class Sync {
public void func(Sync s) {
synchronized (s) {
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func ...");
System.out.println(System.currentTimeMillis());
}
}
}
由于传入的是不同的对象,因此这里不会发生资源的争抢
示例3:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
Integer i = 1;
s.func(i);
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
Integer i = 1;
s.func(i);
}, "t2").start();
}
}
class Sync {
public void func(Integer s) {
synchronized (s) {
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func ...");
System.out.println(System.currentTimeMillis());
}
}
}
这里可以和示例4一起看,是个有趣的问题
示例4:
public class SyncDemo {
public static void main(String[] args) {
Sync s = new Sync();
System.out.println("start time:" + System.currentTimeMillis());
new Thread(() -> {
Integer i = 128;
s.func(i);
}, "t1").start();
try {
System.out.println(" == main sleep == ");
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(() -> {
Integer i = 128;
s.func(i);
}, "t2").start();
}
}
class Sync {
public void func(Integer s) {
synchronized (s) {
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("func ...");
System.out.println(System.currentTimeMillis());
}
}
}
这里就是Integer对-128到127的"缓存"问题,这部分都是在常量池中,如果使用这区间的数就是锁定了同一个对象(如果使用Integer i = new Integer(1);除外)
二、锁的升级
在JDK1.6之前,synchronized叫做重量级锁,申请锁资源需要从用户态切换到内核态(系统调用),JDK1.6以后,为了降低获取锁与释放锁的开销,引入了偏向锁,轻量级锁等的概念,目前在synchronized中,锁存在四种状态:无锁,偏向锁,轻量级锁,重量级锁。
1:内核态用户态与mutex_lock
1:内核态和用户态区别:
内核态(Kernel Mode):运行操作系统程序,操作硬件。
用户态(User Mode):运行用户程序
2:指令划分
特权指令:只能由操作系统使用、用户程序不能使用的指令。 举例:启动I/O 内存清零 修改程序状态字 设置时钟 允许/禁止终端 停机
非特权指令:用户程序可以使用的指令。 举例:控制转移 算数运算 取数指令 访管指令(使用户程序从用户态陷入内核态)
3:CPU状态转换
用户态--->内核态:唯一途径是通过中断、异常、陷入机制(访管指令)
内核态--->用户态:设置程序状态字PSW
重量级锁的底层是使用mutex,互斥锁(Mutex)是在原子操作API的基础上实现的信号量行为。互斥锁不能进行递归锁定或解锁,能用于交互上下文但是不能用于中断上下文,同一时间只能有一个任务持有互斥锁,而且只有这个任务可以对互斥锁进行解锁。当无法获取锁时,线程进入睡眠等待状态。 说白了作用就是防止多个线程同时对一个全局变量进行修改的时候出现同步问题,用互斥锁就是为了在同一时刻只有一个线程在真正的访问某一个资源。
2:对象在Heap中的布局
Object在heap中的布局:
markword: 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位的虚拟机为32bit,64位的虚拟机(未开启压缩指针)中位64bit。
Class poniter: 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例. 32位4字节,64位开启指针压缩或最大堆内存<32g时 4字节,否则8字节。
length: 只有数组对象有, 4字节。int最大值2g,2^31,java数组(包含字符串)最长2g
- 对象头=markword + Class poniter+ length
instance data: 实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
Primitive Type | Memory Required(bytes) |
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
引用类型 | 在32位系统上每个占用4B, 在64位系统上每个占用8B,开启(默认)指针压缩占用4B |
padding: 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
附加:
1:在32位系统下,存放Class指针的空间大小是4字节,MarkWord是4字节,对象头为8字节。
2:在64位系统下,存放Class指针的空间大小是8字节,MarkWord是8字节,对象头为16字节。
3:64位开启指针压缩或者 JVM 堆的最大值小于 32G的情况下,存放Class指针的空间大小是4字节,MarkWord是8字节,对象头为12字节。
4:markword始终为8字节,class pointer及object ref pointer压缩4字节,不压缩8字节,数组对象的Shallow Size=数组对象头(12/16)+数组长度4字节+length * 引用指针大小(4/8)+填充。
5:静态属性不算在对象大小内。
6:JDK 1.8,默认启用指针压缩参数就是开启的。
3:Monitor 对象
每个对象都有一个与之关联的Monitor 对象
ObjectMonitor() {
_header = NULL;
_count = 0; // 重入次数
_waiters = 0, // 等待线程数
_recursions = 0;
_object = NULL;
_owner = NULL; // 当前持有锁的线程
_WaitSet = NULL; // 调用了 wait 方法的线程被阻塞 放置在这里
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 等待锁 处于block的线程 有资格成为候选资源的线程
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
Monitor 是线程私有的数据结构,每一个线程都有一个可用 monitor record 列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个 monitor 关联,同时 monitor 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
线程的声明周期图(线程的生命周期存在5个状态,start、running、waiting、blocking和dead)
对于一个synchronized修饰的方法(代码块)来说:
- 当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocking状态
- 当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取
- 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入_Owner区
- 如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1
4:markword与锁状态
lock:2位的锁状态标记位
biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。
存储内容 | 标志位 | 状态 |
对象哈希码、对象分代年龄 | 01 | 未锁定 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
指向锁记录的指针 | 00 | 轻量级锁 |
指向重量级锁的指针 | 10 | 重量级锁 |
空、无需记录信息 | 11 | GC标记 |
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的指针。
5:锁升级过程
1:无锁:
初期锁对象刚创建时,还没有任何线程来竞争,对象的Mark Word如下图所示,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。
2:偏向锁:
如果一个对象,在每次作业的运行始终处于单一线程,那每次对于锁的检测、获取和释放都会对性能造成不小的消耗,于是java引入了偏向锁。当一个处于匿名偏向锁状态的对象,第一次被一个线程竞争时,其对象头会被标记为偏向锁,同时存储其线程指针,接下来每次该线程对该锁的获取都是比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致不需要经过不必要的CAS判断锁资源,从而优化了性能,这也是偏向的由来 。当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如下图所示情形。
匿名偏向锁状态的对象如果计算了hashCode,则会变为无锁状态。hashCode存在markword,并且接下来不会再进去偏向锁。匿名偏向锁状态的对象被获取时,进入非匿名偏向锁状态,markword存储持有者的java线程在操作系统的C语言指针。无锁状态下的对象被获取时,会直接跳到轻量级锁。非匿名偏向锁状态的对象计算了hashCode以后,会直接进入重量级锁。默认偏向锁的开启时在虚拟机运行后延时4秒。
上面的描述,比较当前线程的threadID和Java对象头中的threadID是否一致。如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
3:轻量级锁:
当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了。当一个对象处于非匿名偏向锁状态下,如果有别的线程过来竞争,另一个线程尝试竞争锁,竞争失败并给予jvm一个竞争的信号以后进入自旋(不断尝试获取锁), 接下来在持有该锁的线程执行来到安全点时,会触发stop the world并将膨胀为轻量级锁。 轻量级锁会创建一份锁记录(Lock Record)在当前持有他的线程的线程栈里。LockRecord中包含一个owner属性指向锁对象,而锁对象的markword中也会保存一个执行该LockRecord的指针。 轻量级锁的竞争时,竞争锁的线程会在一个周期时间内不断的自旋获取锁,如果获取失败就会进入阻塞并将markword的锁标记标记为10(重量级锁)。因为LockRecord复制了markword,所以在执行同步块时并不去关注markword,只有到了释放时
1.锁对象的markword升级为了重量级锁,将锁对象升级为重量级锁,锁对象的markword存储一个指向一个由操作系统实现的mutex互斥变量,唤醒阻塞的竞争线程
2.markword没变化,释放锁,对象锁恢复到无锁状态(如果LockRecord记录的是偏向锁,则恢复到匿名偏向锁,否则恢复到无锁状态)
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放(默认允许循环10次)。
4:重量级锁:
如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。当对象来到重量级锁以后,新被从竞争队列挑选出来一部分竞争锁的线程队列会一起竞争锁,最终竞争到锁的一个线程会继续运行,竞争失败的线程进入阻塞队列。处于执行状态的线程执行完同步代码块后,会释放锁并唤醒阻塞队列中的线程,将他们加入新的挑选出来的竞争锁的线程队列,并重新竞争锁,重复以上操作。因为需要阻塞和唤醒线程,所以需要从用户态到系统态切换,所以重量级锁下的系统开销很大 。此时这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。如下图所示情形。
为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
锁分类与对比
三、锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
四、锁的常见使用原则
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。