引言
在Java中实现多线程同步访问共享变量的最广泛实现方法就是使用关键字synchronized
。
上图是JVM内存模型。
我们知道JVM内存结构主要分为三大块:堆、栈和方法区。每个线程都有自己的一个虚拟机栈,栈保存着局部变量以及所有调用的方法的参数和返回值。其他线程无法访问该线程的栈中数据。栈仅能保存基本类型和对象引用,对象是存放在堆中的。堆内存和方法区中的静态变量等数据可以被线程共享,而同步处理针对的正是共享数据。
所以这里的虚拟机栈就可以被认为是每个线程的工作内存,方法区和堆内存可以被认为是主内存。
synchronized使用场景
synchronized关键字可以作用于方法或者代码块,最主要有以下几种使用方式,如图:
synchronized不同的修饰方式,锁的对象也不同:
- 修饰实例方法:锁是当前实例对象,进入同步代码前要获得当前实例的锁
public class SynchronizedTest implements Runnable {
static SynchronizedTest synchronizedTest = new SynchronizedTest();
//共享资源
static int i = 0;
public synchronized void increase() {
System.out.println(Thread.currentThread().getName()+"已经进入同步方法中");
i++;
System.out.println(Thread.currentThread().getName()+"操作后i的值为:"+i);
System.out.println(Thread.currentThread().getName()+"离开同步方法中");
}
@Override
public void run() {
increase();
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(synchronizedTest,"线程A");
Thread thread2 = new Thread(synchronizedTest,"线程B");
thread1.start();
thread2.start();
}
}
这里synchronized修饰的是同步方法,锁住的是实例对象(这里是synchronizedTest对象),所以线程A和线程B会争夺这一个对象,结果如下
结果分析:线程A先拿到该对象的锁,并进入到同步方法中,当执行完毕后,释放对象的锁,然后线程B才能获得对象锁。
下面的代码稍加改动,将上面争用同一把对象锁,改为不同两个对象锁
public class SynchronizedTest implements Runnable {
//共享资源
static int i = 0;
public synchronized void increase() {
System.out.println(Thread.currentThread().getName()+"已经进入同步方法中");
i++;
System.out.println(Thread.currentThread().getName()+"操作后i的值为:"+i);
System.out.println(Thread.currentThread().getName()+"离开同步方法中");
}
@Override
public void run() {
for(int i=0;i<5;i++)
increase();
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new SynchronizedTest(),"线程A");
Thread thread2 = new Thread(new SynchronizedTest(),"线程B");
thread1.start();
thread2.start();
}
}
这里ThreadA访问实例对象obj1的synchronized方法,ThreadB访问实例对象obj2的synchronized方法,这样是允许的,因为两个实例对象锁并不相同。结果两个线程交替执行,不会因争夺同一把锁而发生阻塞。
- 修饰静态方法:锁是当前类的class对象,进入同步代码前要获得当前类对象的锁
这里将同步方法改为静态后,还是new
两个不同的实例对象,按照第一种方式,应该不会发生阻塞,但是这里的synchronized修饰静态方法,后锁住的是当前类的class对象(这里为SynchronizedTest.class
)
public class SynchronizedTest implements Runnable {
//共享资源
static int i = 0;
public static synchronized void increase() {
System.out.println(Thread.currentThread().getName()+"已经进入同步方法中");
i++;
System.out.println(Thread.currentThread().getName()+"操作后i的值为:"+i);
System.out.println(Thread.currentThread().getName()+"离开同步方法中");
}
@Override
public void run() {
increase();
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(new SynchronizedTest(),"线程A");
Thread thread2 = new Thread(new SynchronizedTest(),"线程B");
Thread thread3 = new Thread(new SynchronizedTest(),"线程C");
Thread thread4 = new Thread(new SynchronizedTest(),"线程D");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
所以不管你new多少个实例对象,而当前类对象就只有一个,所以多线程争用一个类对象的话是会发生阻塞的。
- 修饰代码块
synchronized(this):锁是当前实例对象,
synchronized(AccountingSync.class): 锁是class对象
monitorenter、monitorexit、ACC_SYNCHRONIZED
这三个单词是代表着synchronize底层实现的核心。我们使用jdk自带工具javap -verbose xxx.class
,将字节码文件反汇编
一、synchronized修饰代码块
public void dec1(){
synchronized (this){
i--;
}
}
反汇编:
二、synchronized修饰方法
public synchronized void dec(){
i--;
}
反汇编:
我们看到synchronized修饰的方法在字节码中添加了一个ACC_SYNCHRONIZED
的flags,同步代码块则是在同步代码块前插入monitorenter
,在同步代码块结束后插入monitorexit
。
- 同步方法:方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个
ACC_SYNCHRONIZED
标志。
当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁
,然后开始执行方法,方法执行之后再释放monitor锁
,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。
在这期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。
如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
- 同步块:
每个对象都与一个monitor
相关联。当且仅当拥有所有者时(被拥有),monitor
才会被锁定。执行到monitorenter
指令的线程,会尝试去获得对应的monitor
,如下:
每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程获得monitor
(执行monitorenter指令)时,会把计数器设置为1.
当同一个线程再次获得该对象的锁的时候,计数器再次自增。(这里可以解释的通synchromized为啥是可重入锁)
当其他线程想获得该monitor
的时候,就会阻塞,直到计数器为0才能成功。
线程执行monitorexit
指令,就会让monitor
的计数器减一。如果计数器为0,表明该线程不再拥有monitor
。其他线程就允许尝试去获得该monitor
了。
- 同步代码块是通过monitorenter和monitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
- 同步方法是通过中设置ACC_SYNCHRONIZED标志来实现,当线程执行有ACC_SYNCHRONI标志的方法,需要获得monitor锁。
- 每个对象维护一个加锁计数器,为0表示可以被其他线程获得锁,不为0时,只有当前锁的线程才能再次获得锁。
- 当monitor的计数器不为0时,synchronized可实现可重入锁。
- 同步方法和同步代码块底层都是通过monitor来实现同步的。
每个对象都与一个monitor相关联,线程可以占有或者释放monitor。
monitor监视器
到目前为止我们还是不知道monitor
到底是什么呢?它可以理解为一种同步工具,或者说是同步机制,它通常被描述成一个对象。操作系统的管程是概念原理,ObjectMonitor是它的原理实现。(操作系统中管程是同步机制的封装,可以理解为将各种信号量封装成程序员无需了解底层的API接口).
在Java虚拟机(HotSpot)中,Monitor(管程)是由ObjectMonitor实现的,其主要数据结构如下:
public class ObjectMonitor extends VMObject {
private static ObjectHeap heap;
private static long headerFieldOffset;
private static long objectFieldOffset;
private static long ownerFieldOffset;
private static long FreeNextFieldOffset;
private static CIntegerField countField;
private static CIntegerField waitersField;
private static CIntegerField recursionsField;
其中最重要的是:
下图为获取/释放monitor示意图
- 想要获取monitor的线程,首先会进入_EntryList队列)。
- 当某个线程获取到对象的monitor后,进入_Owner区域,设置为当前线程,同时计数器_count加1。
- 如果线程调用了wait()方法,则会进入_WaitSet队列。它会释放monitor锁,即将_owner赋值为null,_count自减1,进入_WaitSet队列阻塞等待。
- 如果其他线程调用 notify() / notifyAll(),会唤醒_WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入_Owner区域。
- 同步方法执行完毕了,线程退出临界区,会将monitor的owner设为null,并释放监视锁。
下面我们还用弄清对象如何与monitor发生关系的?
我们知道Java中一切都是对象,Hotpo将对象模型拆为oop和klass,下面表示JVM内存中如何创建一个实例对象的。
class Model
{
public static int a = 1;
public int b;
public Model(int b) {
this.b = b;
}
}
public static void main(String[] args) {
int c = 10;
Model modelA = new Model(2);
Model modelB = new Model(3);
}
通过这两张图,就可以理解到实例对象的对象头是存储在堆空间的,对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
我们知道synchronized是重量级锁,那么根据上图所示,MarkWord的前28位表示monitor的地址。
java虚拟机对Synchronized的优化
JVM优化synchronized运行的机制,当JVM检测到不同的竞争情况时,会自动切换到适合的锁实现
- 当没有竞争出现时,默认会使用偏斜锁。JVM 会利用
CAS
操作,在对象头上的Mark Word部分位设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。 - 有竞争出现时,当有另外的线程试图锁定某个已经被偏斜锁锁定的对象,jvm就会撤销revoke偏斜锁,并切换到轻量级锁。轻量级锁依赖
CAS
操作Mark Word来试图获取锁,如果成功,就使用轻量级锁,否则继续升级未重量级锁