- 前言
- 1. 线程运行的原理
- 2. 程序的运行过程
- 一、共享带来的问题
- 1. 问题的引入
- 2. 问题的分析
- 3. 新概念的引入
- 4. 解决方案
- 二、synchronized关键字
- 1. 语法
- 1.1 同步代码块
- 1.2 同步方法
- 1.3 同步静态方法
- 1.4 解决方式图解
- 2. 线程安全性分析
- 2.1 成员变量与静态变量
- 2.2 局部变量
- 3. 常见的线程安全类
- 3.1 同步方法保证线程安全类
- 3.2 不可变性保证线程安全类
- 4. synchronized的底层原理
- 4.1 Java对象头
- 4.2 Monitor原理
- 4.3 重量级锁的加锁过程
- 4.4 重量锁进一步解读:字节码分析
- 4.4 轻量级锁的加锁过程
- 4.5 锁的膨胀
- 4.6 自旋优化
- 4.7 偏向锁
- 4.8 注意
- JVM内存模型中的栈实际上就是给线程使用的。
- 每个线程启动以后,虚拟机就会为其分配一块栈内存。
- 每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存。
- 每个线程只能为有一个活动栈帧,对应着当前正在执行的方法。
public class Process{
public static void main(String[] args){
method1();
}
private static void method1(){
int y = x + 1;
Object m = method2();
System.out.println(m);
}
private static Object method2(){
Object n = new Object();
return n;
}
}
一、共享带来的问题
1. 问题的引入
- 假设有一个变量count,两个线程t1和t2,t1线程对变量count执行5000次加一操作,t2线程对变量count执行5000次减一操作,结果却出现了count为正数、负数、零三种状况。
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 1;i < 5000; i++){
count--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count的值是{}",count);
}
2. 问题的分析
1.count++操作的字节码
getstatic i // 获取静态变量i的值 iconst_1 // 准备常量1 iadd // 自增 putstatic i // 将修改后的值存入静态变量i
2.count–操作的字节码
getstatic i // 获取静态变量i的值 iconst_1 // 准备常量1 isub // 自减 putstatic i // 将修改后的值存入静态变量i
3.单线程情况不会出现问题
4.多线程情况下问题的产生(以负数为例)
5.总结
- 问题出现在多个线程访问共享资源时,读写操作指令发生了交错。
- 临界区:一段代码块内如果存在对共享资源的多线程读写操作,我们称该段代码块为临界区。
- 竞态条件:多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,就称之为发生了竞态条件。
- 阻塞式的解决方案:synchronized、Lock
- 非阻塞式的解决方案:原子变量
synchronized{
//临界区
}
1.2 同步方法
public class Test {
// 在方法上加上synchronized关键字
public synchronized void test() {
}
// 等价于
public void test() {
synchronized(this) { // 锁住的是对象
}
}
}
1.3 同步静态方法
public class Test {
// 在静态方法上加上 synchronized 关键字
public synchronized static void test() {
}
//等价于
public void test() {
synchronized(Test.class) { // 锁住的是类,是java.lang.Class类的对象
}
}
}
1.4 解决方式图解
synchronized实际上使用对象锁保证了临界区内代码的原子性,临界区内的代码是不可分割的,不会被线程切换所打断。
- 如果成员变量和静态变量没有被共享,则线程安全。
- 如果成员变量和静态变量被共享且只有读操作,则线程安全。
- 如果成员变量和静态变量被共享且存在写操作,则线程不安全。
- 局部变量如果始终是基本数据类型,则线程安全。
- 局部变量如果是引用数据类型,且该数据类型不被其他的线程所操作,则线程安全。
- 局部变量如果是引用数据类型,且该数据类型被其他的线程所操作,则线程不安全,比如定义了局部变量的方法中调用的方法新建了一个线程同时操作这个成员变量,此时,线程就是不安全的。
1.线程安全类
- StringBuffer
- Random
- Vector(List的线程安全实现类)
- Hashtable(Hash的线程安全实现类)
- java.util.concurrent 包下的类
2.解释
- 这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,因为他们的方法都是用sychronized修饰。
- 线程安全类的方法组合以后,方法不再是原子的,即是非线程安全的。
//组合后线程不安全 Hashtable table = new Hashtable(); if(table.get(key) == null){ table.put(key, value); }
1.线程安全类
- String
- Integer
2.解释
- String和Integer类都是不可变的类,因为其类内部状态是不可改变的,因此它们的方法都是线程安全的,尽管String有replace,substring 等方法可以改变值,但其实这些方法实现的原理是在底层新创建了一个对象,对新的对象进行操作并返回,而并非直接对原对象进行操作。
1.普通对象
- Mark Word:通常包括该对象的hashcode、分代年龄、是否偏向锁、加锁状态。在不同的加锁状态下有所区别。
- Klass Word:指针,指向对应的java.lang.Class实例,表明该对象的类别。
2.数组对象
- Mark Word:通常包括该对象的hashcode、分代年龄、是否偏向锁、加锁状态。在不同的加锁状态下有所区别。
- Klass Word:指针,指向对应的java.lang.Class实例,表明该对象的类别。
- array length:数组长度。
3.Mark Word结构
-
4.2 Monitor原理1.基本概念
- Monitor即监视器,也被称为管程。每个Java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(重量级),该对象头的Mark Word中就被设置为指向Monitor对象的指针。
2.结构解析
- WaitSet中存放的是持有过该锁但通过wait()方法进入了WAITING状态的线程。
- EntryList中存放的是被阻塞进入BLOCKED状态的线程。
- Owner中存放的是当前正在持有该锁的线程。
- 起始时刻,Monitor中的Owner为null。
- 当Thread-2执行synchronized(obj){} 代码加上重量级锁时,首先将obj对象头的Mark Word改为Heavweight Locked状态(即利用10表示重量级锁,前为30bit为指向某个Monitor的指针),随后将Monitor的所有者Owner设置为Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner。
- 当Thread-2占据锁时,如果线程Thread-1也来执行synchronized(obj){} 代码,就会找到obj指向的Monitor对象,并进入EntryList(阻塞队列)中变成BLOCKED(阻塞)状态。
- Thread-2执行完同步代码块的内容,将Owner置为null,然后唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的。
- 如果线程Thread-2之间存在一个线程先持有了该锁并执行了wait(long),则会被放入WaitSet中进入WAITING(等待)状态。
注意:synchronized必须是进入同一个对象的monitor才有上述的效果。如果不是同一个synchronized的对象,不遵循以上规则;如果不加synchronized的对象,不遵从以上规则。
4.4 重量锁进一步解读:字节码分析static final Object lock = new Object();
static int counter = 0;
public static void main(String[] args){
synchronized(lock){
counter++;
}
}
Exceprion table中的意思是从from到to如果发生任何异常,都会转到target来继续执行。
1.轻量级锁的使用场景
- 对于一个对象,如果有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。
- 轻量级锁对使用者是透明的,其语法仍然是synchronized,轻量级锁的选择实际上是JVM进行的。
- 如果轻量级锁加锁不成功,即出现了竞争的情况,则轻量级锁会自动升级为重量级锁。
2.轻量级锁的加锁过程
//以下代码,method1()和method()2加锁的时间就是错开的,即不会竞争锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
-
每当代码执行到synchronized代码块时,都会创建一个锁记录(Lock Record)对象,线程的每个栈帧中都存放着一个锁记录对象,锁记录内部包含两个部分,起始时一部分存放了锁记录对象的地址和00标志位(00标志代表轻量级锁),另一部分为空(全为0)。
-
让锁记录中的Object reference指向要加锁的Java对象的存储地址,并尝试用CAS方式来交换lock record地址和00标志位和要加锁的Java对象的Mark Word。
-
如果交换成功,则表示加上了轻量级锁。此时Java对象的对象头中存储了锁记录对象的地址和00标志位。
-
如果交换失败,则存在两种情况:
情况一:自己执行了synchronized锁的重入,此时仍然需要添加一个锁记录对象,然后进行CAS交换并指向Java对象的引用,但此时CAS交换失败,新增加的锁记录对象中锁记录对象的地址和00标志位的位置为null且Object reference部分指向Java对象的锁记录。此时的锁记录对象作为重入的计数。
情况二:其他线程已经持有了该Java对象的轻量级锁,表明存在竞争,进入锁的膨胀过程。 -
当解锁时,分为三种情况考虑
情况一:正常情况,直接使用CAS将Mark Word的值恢复给Java对象头时,取出的值不为null,且交换成功即表明解锁成功。
情况二:存在重入锁,如果进行CAS交换时,发现取值为null,则表示有重入锁,此时删除该锁记录对象,表示重入计数减一。
情况三:锁已膨胀,如果进行CAS交换时,发现取值不null,且交换失败,此时说明轻量级锁已膨胀为重量级锁,则进入重量级锁解锁流程。
1.基本概念
- 如果在尝试加轻量级锁的过程中,CAS操作无法成功,则说明其它线程已经为这个对象加上了轻量级锁,就要进行锁膨胀,将轻量级锁变成重量级锁。
2.流程
-
当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁,这时Thread-1加轻量级锁失败,进入锁膨胀流程。
-
首先,为对象申请Monitor锁,让Object指向重量级锁地址,随后,然后自己进入Monitor的EntryList变成BLOCKED状态。
-
当Thread-0退出synchronized同步块时,使用CAS将Mark Word的值恢复给对象头,对象的对象头指向Monitor,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null ,唤醒EntryList中的Thread-1线程。
-
重量级锁竞争的时候,还可以使用自旋来进行优化。
-
如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁。
-
如果自旋重试失败(即自旋了一定次数还是没有等到持锁的线程释放锁),则依然要进入阻塞状态。
-
自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。在Java6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。Java7之后不能控制是否开启自旋功能。
1.基本概念
- 在轻量级的锁中,如果同一个线程对同一个对象进行重入锁时,也需要执行CAS操作,这是也会产生时间消耗,因此Java6中,开始引入了偏向锁来进行优化。
- 偏向锁即只有第一次使用CAS时将对象的Mark Word头设置为偏向线程ID,之后,该线程再次进行重入时,如果发现线程ID是自己,则表示没有竞争,那么就不用再进行CAS交换。
2.轻量级锁和偏向锁的对比
static final Object obj = new Object();
public static void m1() {
synchronized(obj) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized(obj) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized(obj) {
// 同步块 C
}
}
3.偏向状态
- 如果开启了偏向锁(默认开启),那么对象刚创建之后,Mark Word最后三位的值101,其余位都是0。
- 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:
-XX:BiasedLockingStartupDelay=0来禁用延迟 - 如果不开启偏向锁,此时对象创建后,Mark Word最后三位的值位001,其余位都是0。该对象的hashcode、age等都为0,hashcode在第一次被调用时,才会在对象头中赋值。
- 当我们对一个Java对象第一次进行加锁时,实际上加的就是偏向锁,此时访问该锁的线程的id会被记录在该对象的Mark Word中。注意,此线程id和Java中的getId()得到的id并不一样,此时的id时操作系统赋予的。
- 可以通过添加VM参数-xx:-UseBiasedLocking来禁用偏向锁,UseBiasedLocking前的-就代表禁用,如果是+代表启用。
4.偏向锁的撤销
- 如果在对Java对象加锁之前,调用了该Java对象hashcode,则会禁用该Java对象的偏向锁,因为已经将hashcode存放在对象头中了,无法存放线程的id了。
- 当有其他线程想对该Java对象加锁时,会撤销该Java对象的偏向锁,将其升级为轻量级锁(时间错开,没有竞争,否则会升级成重量级锁)。
- 调用wait-notify必然会Java对象的偏向锁,因为此时,不光多个线程访问,而且存在竞争,直接升级为重量级锁。
5.批量重偏向
- 如果起始时,对大量的Java对象都设置了偏向于A线程的锁。此时,不断用B线程,重新获取这些锁,则被B线程访问的锁会不断地被撤销偏向锁升级为轻量级锁。当撤销次数超过20次后,所有设置了偏向于A线程的Java对象的且未撤销的锁,都会设置偏向线程B的锁。
- 当撤销超过20次后(超过阈值),JVM 会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程。
6.批量撤销
- 接着上述的情况,如果此时还有线程C对这些Java对象再次进行加锁,此时会对撤销Java对象对B线程的偏向锁,当总撤销次数超过40次时,所有偏向锁都会被撤销,升级成轻量级锁,此时,哪怕对新的Java对象加锁,也不会加偏向锁,而是加轻量级锁。
7.锁消除
- 如果加锁的Java对象不能被共享,则JIT编译器在进行编译时会消除锁,来提升运行效率。
- 可以通过设置VM参数-XX:-Eliminatelocks来禁止锁的消除。
- 加锁的顺序:当为Java对象加锁时,优先加偏向锁。如果其他线程用了该Java对象,则撤销偏向锁,变为轻量级锁。如果发生了竞争,则轻量锁会膨胀为重量级锁。
- 上述轻量级锁和偏向锁都以重入锁为示例进行说明,但是实际并不要求是重入锁,任何情况下都是这个加载顺序,以重入锁为例只是方便解释说明。



