并发问题
如上图所示,当多个线程同时访问某一个变量时,线程首先会把共享的变量读取到线程私有的工作内存中,以便提高线程对变量的访问速度。当变量值发生变化时先修改工作内存中的值,然后再回刷回主内存中,这样当多线程同时访问某一个变量时候,自己私用的变量值很有可能不相等。如上图中,当Thread1和Thread2同时回写主内存时,就会存在值的覆盖问题,最终造成数据的不准确。
下面我们用代码创建两个线程模拟多线程访问同一个变量的问题。
public class ConcurrencyThread {
private static int a = 0;
private static void autoAdd(){
a++;
}
static class AddThread extends Thread{
ConcurrencyThread concurrencyThread = new ConcurrencyThread();
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
concurrencyThread.autoAdd();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[2];
for (int i = 0; i < 2; i++) {
threads[i] = new Thread(new AddThread());
threads[i].start();
}
threads[0].join();
threads[1].join();
System.out.println("a == "+a);
}
}
上面代码中,我们使用了两个线程,分别对a变量循环自增1000次,等确保两个线程run()方法的逻辑都执行完成之后输出a。我们想要的预期结果应该是2000,实际执行完之后可能不是2000。比如我找了一次运行之后不是2000的结果:
使用方法
- 同步方法
synchronized可以用来修饰整个方法,以保证多个线程访问该方法时的同步和互斥。
例如:
public synchronized void method() {
// ...
}
- 同步代码块
synchronized还可以用来修饰代码块,以保证多个线程访问该代码块时的同步和互斥。代码块需要指定锁对象,锁对象可以是任意对象,但是在多线程共享对象的情况下,应该使用共享对象作为锁对象。
例如:
public void method() {
Object lock = new Object();
synchronized(lock) {
// ...
}
}
需要注意的是,在synchronized中提供了两种锁:类锁、对象锁。
类锁是全局的,即使多个线程调用不同的对象实例时也会产生互斥。类锁锁的是Class文件,Class文件在JVM启动过程中每个.class文件会在元空间中加载产生一个Class对象,Class对象在JVM进程中是全局唯一的。
当synchronized作用在方法时使用static修饰时候锁的作用范围是类锁;当在代码块synchronized的括号里是一个Class对象时候锁的作用范围同样是类锁。
对象锁,锁的范围是一个对象实例。
类锁和对象锁有点类似表锁和行锁,锁的粒度不同。
对象头
synchronized实现线程之间的同步,是通过修改对象头中的参数来控制的,所以我们先来看下对象头。
java对象头有三部分组成:
- Class Metadata Address(类元数据指针),这是一个指向对象类型元数据的指针。
- Array Length(数组长度),用于记录数组的长度,此字段仅限制于数组对象,如果不是数组对象,此字段不存在。
- Mark Word,这个字段是Java对象头中最重要的部分,包括了对象的hash码、GC分代年龄和锁相关的标志位。
mark word
32位操作系统
锁状态 | 25bit | 4bit | 1bit | 2bit | |
23bit | 1bit | 是否偏向锁 | 锁标记位 | ||
无锁 | 对象的hashCode | 分代年龄 | 0 | 01 | |
偏向锁 | 线程ID | Epoch | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | |||
重量级锁 | 指向重量级锁的指针 | 10 | |||
GC标记 | 空 | 11 |
64位操作系统
锁状态 | 56bit | 1bit | 4bit | 1bit | 2bit | |
25bit | 31bit | 是否偏向锁 | 锁标记位 | |||
无锁 | unused | 对象的hashCode | cmc_free | 分代年龄 | 0 | 01 |
偏向锁 | 线程ID(54bit) | Epoch(2bit) | 分代年龄 | 分代年龄 | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 00 | ||||
重量级锁 | 指向重量级锁的指针 | 10 | ||||
GC标记 | 空 | 11 |
锁升级
在synchronized中引入了偏向锁、轻量级锁、重量级锁,通过修改对象头的锁标记为来记录锁的类型,当前线程具体会用到哪种类型锁,要根据当前的并发激烈程度。随着并发程度的提升,锁会沿着无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁的方向升级,并且锁一旦发生升级便不可逆向。
偏向锁
偏向锁其实可以理解为在没有竞争的情况下访问synchronized修饰的代码块的枷锁场景,目的是减少多线程竞争下的锁消耗,提高程序运行效率。在无竞争的情况下,偏向锁可以降低锁的重量级,使得锁的性能与非锁状态相当。
在没有线程竞争的情况下访问synchronized代码块时,会先基于CAS把对象头中的线程ID修改为当前线程id,如果修改成功,则说明偏向锁抢占成功。此时对象头中的信息如上面的表格中偏向锁的一行。当抢占成功的线程再次进入同样的synchronized代码块时代码块时,只需要比较线程id与对象头中的线程ID是否相等即可判断是否可以执行代码块中的逻辑。
偏向锁的优点在于可以减少多线程竞争下的锁消耗,避免不必要的锁竞争,提高程序的执行效率。但是偏向锁也存在一些缺点,比如当多个线程竞争同一把锁时,偏向锁会变得无效,此时需要升级为轻量级锁或重量级锁;偏向锁会增加对象头的额外存储,增加了内存开销。因此,在实际应用中,偏向锁的使用需要根据具体的场景进行评估。
CAS(乐观锁)
CompareAndSwap即CAS,比较并替换,所以它是一种乐观锁的思想。CAS方法中会传入三个参数,第一个参数V表示要更新的变量,第二个参数E表示期望值,第三个参数参数U表示更新后的值。如果V==E,期望值和实际值相等,则将V修改成U。
乐观锁也需要依赖系统层面的锁
如果多个线程调用CAS,并且同时去执行期望值与实际值的判断,那么也应该存在原子性问题才对。
为解决这个问题JVM在底层的CAS实现上增加了一个Lock指令,来保证它的原子性。
轻量级锁
如果偏向锁存在竞争,那么当前线程就会触发锁膨胀,采用轻量级锁来抢占锁资源。
轻量级锁的实现原理是,当一个线程获得锁时,虚拟机会将对象头Mark Word中的标志位设置为“轻量级锁”,并将线程ID记录在对象头中。此时,虚拟机会将对象头中的指针指向线程栈中的锁记录(Lock Record)。如果其他线程想要获取该锁,虚拟机会检查对象头中的线程ID是否与当前线程ID相同,如果相同,则表示当前线程已经获取了该锁,可以直接执行同步块中的代码;否则,虚拟机会尝试使用自旋锁(自旋+CAS)的方式,等待锁的释放。如果自旋等待的时间超过一定的限制,虚拟机会将锁升级为重量级锁,此时需要使用操作系统的互斥量来保证同步。
轻量级锁的优点在于可以减少线程的上下文切换和线程阻塞,提高程序的执行效率。但是轻量级锁也存在一些缺点,比如在竞争激烈的情况下,自旋等待会消耗大量的CPU时间,影响程序的性能。因此,在实际应用中,轻量级锁的使用需要根据具体的场景进行评估。
重量级锁
当多个线程竞争同一个锁时,如果轻量级锁不能成功获取锁,那么虚拟机会将锁膨胀为重量级锁。重量级锁是一种阻塞锁,其实现方式是使用操作系统的互斥量来保证同步,线程在获取锁失败后,会进入阻塞状态,直到获得锁之后才会继续执行。
在获取重量级锁之前,会先实现锁的膨胀,在膨胀方法中首先创建一个ObjectMonitor对象。具体来说,每个Java对象在虚拟机内部都对应着一个监视器(monitor)对象,用于实现同步。当一个线程需要获得某个对象的锁时,它会尝试获取该对象的监视器,如果获取失败,则进入阻塞状态,等待其他线程释放该对象的锁。
在Java虚拟机中,重量级锁的实现涉及到以下几个步骤:
当一个线程需要获取某个对象的锁时,它会尝试获取该对象的监视器锁(monitor lock),如果该锁没有被其他线程占用,则该线程可以获得锁。
如果该锁已经被其他线程占用,则当前线程会进入阻塞状态,等待锁的释放。
当一个线程获得了锁之后,它可以执行一些操作,然后释放锁。
当一个线程释放锁之后,它会唤醒等待该锁的其他线程,让它们继续竞争锁。