一、锁概述
1.1 锁的分类
这一小节说的锁指的是锁的思想,并不是实际的锁对象。常见的锁又如下几种:
1.1.1自旋锁:
在循环语句中使用CAS技术尝试更新数据,执行失败则一直在自我循环,直到成功为止,故称为自旋锁。
1.1.2 悲观锁、乐观锁
- 悲观锁:默认其他操作(线程)会更改当前操作(线程)的目标对象,当前操作(线程)会从读数据开始就会直接锁住目标对象,直到操作完成(synchronized锁是典型的悲观锁)。
- 乐观锁:默认其他操作(线程)不会更改当前操作(线程)的目标对象,在执行写操作时,如果现有数据与自己持有的就数据一致,则执行写操作,否则操作失败。(执行sql update时常用)
1.1.3 独享锁(写锁)、共享锁(读锁)
- 独享锁(写锁):也叫排他锁,是指该锁一次只能被一个线程锁持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排他锁的线程既能读数据又能修改数据(synchronized和JUC中Lock的实现类就是互斥锁)。
- 共享锁(读锁):指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排他锁。获得共享锁的线程只能读数据,不能修改数据。
1.1.4 可重入锁、不可重入锁:
这里两者的区别在于,在已获取当前锁的情况下,如果能自由进入依旧由当前锁加锁的代码而无需再次获取锁,则为可重入锁。
1.1.5 公平锁、非公平锁:
这里两者的区别在于是否顺序获取锁,后来的线程却先获取来锁,则是非公平锁。
1.2 synchronized关键字
Java中的每个对象都与一个监视器对象相关联,有且只有一个线程能锁定或解锁。任何尝试锁定该监视器的其他线程都会被阻塞,直到获取该监视器的锁。
synchronized关键字还能保证变量的可见性(读写操作都是操作的住内存)。
特性:
- 是悲观锁
- 是独占锁(排他锁)
- 是可重入锁
在使用synchronized锁时,若未指明锁对象,则默认持有this(当前对象)当作锁对象。⚠️注意:对于同一操作,不同线程之间一定要持有同一个锁对象,加锁操作才有意义:
1.2.1 持有不同synchronized锁对象
public class Demo311 {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (this) {
demoMethod();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (this) {
demoMethod();
}
}
}).start();
}
private static void demoMethod() {
System.err.println(Thread.currentThread().getName()+" 正在读取数据……");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println(Thread.currentThread().getName()+" 正在写入数据……");
}
}
可以看到,两个线程在交替执行,执行结果:
Thread-0 正在读取数据……
Thread-1 正在读取数据……
Thread-0 正在写入数据……
Thread-1 正在写入数据……
1.2.2 持有相同锁对象
锁对象可以是一个共享的obj、或字节码(class)对象。
public class Demo311 {
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
synchronized (Demo311.class) {
demoMethod();
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
synchronized (Demo311.class) {
demoMethod();
}
}
}).start();
}
private static void demoMethod() {
System.err.println(Thread.currentThread().getName()+" 正在读取数据……");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println(Thread.currentThread().getName()+" 正在写入数据……");
}
}
可以看到,等当前现在执行完毕,另一个线程才开始执行
Thread-0 正在读取数据……
Thread-0 正在写入数据……
Thread-1 正在读取数据……
Thread-1 正在写入数据……
1.3 锁消除与锁粗化
锁消除与锁粗化是JVM层面针对锁做的优化,无需硬编码。
1.3.1 锁消除
锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。
1.3.2 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部(由多次加锁编程只加锁一次)。
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。
锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
二、深入理解synchronized关键字
2.1 synchronized加锁的原理
2.1.1 对象头
对象在提交到JVM中时,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。一个对象头的基本包含如下几个部分:
- mark word:存储对象自身的运行时数据,如hashcode、gc分代年龄、锁标志位等。也是实现synchronized加锁的核心部分。
- class matedate address:指向类的元数据地址(JVM据此确定当前对象属于哪个类)
- array length(数组长度,仅数组对象拥有)
2.1.2 加锁原理
-
未加锁的对象中,对象头的
mark word中存储的是hashcode、gc分代年龄、锁标志位等信息,无锁状态下的锁标志位为01:
-
线程在尝试获取锁时,使用CAS机制改写锁对象中对象头的mark word信息,改写成功则获取到锁(锁标志位改为00,加锁成功)。会在当前线程中的栈空间空开辟一块空间,存储对象的hashcode、gc分代年龄、锁标志位等信息,而锁对象的请求头中存储的是线程中存储原对象头信息的内存地址。其他线程过来获取锁对象时,发现锁对象已被其他线程持有,则进入阻塞状态。而解锁的过程就是逆向回写到锁对象的mark word中:

2.2 synchronized锁的升级
完整的mark word中会存储如下的某一种信息,表示加锁/未加锁/GC回收:

2.2.1 偏向锁到轻量级锁
偏向锁是JDK为我们提供的synchronized锁优化,JDK认为,大多数场景下时同一个线程在对同步代码块执行加锁解锁操作,这样的操作可以只为其提供偏向锁。
偏向锁是默认开启的,线程A第一次获取锁时,锁对象的mark word字段会记录对应线程A的id,如果解锁后,下次还是同一个线程A来获取锁对象,则无需获取锁对象而是直接执行,这样就减小了加锁解锁的性能消耗。偏向锁的本质就是无锁
但是如果出现了另一个线程B来尝试获取锁对象,则自动由偏向锁升级为轻量级锁:开辟线程的栈空间,存入锁对象中对象头的mark word信息,回写对应的内存地址。如果获取锁失败,则使用CAS机制循环尝试获取锁对象。

2.2.2 重量级锁
如果一直未能获取锁对象,CAS尝试次数过多(次数可通过参数配置),则轻量级锁将会升级为重量级锁,线程也会进入阻塞状态。
