synchronized关键字和底层实现机制


1. synchronized关键字的用法

synchronized关键字可以修饰方法(静态或者非静态都可以)和代码块 (括号里放同步对象)。

public class TestSynchronized {
public synchronized void fun1() { // Usage 1
System.out.println("hello fun1");
}
public static synchronized void fun2() { // Usage 2
System.out.println("hello fun2");
}
public void fun3() {
synchronized(UseSynchronized.class) { // Usage 3
System.out.println("hello fun3");
}
}
}

2. synchronized的作用

2.1. 互斥访问

多线程环境下,保证同步方法和同步块的互斥访问。例如:上面的fun1方法,它是对象方法,锁定的是this对象。当多个线程通过同一个 TestSynchronized 对象来调用 fun1 时,任何时刻只有一个线程能进入方法体去执行方法,其他线程则需要等待。

对于同步的静态方法,上面的 fun2,锁定的是类的 class 对象,一样的道理,多个线程调用 TestSynchronized.fun2() 时只有一个线程能执行,其他线程需要等待。

对于同步块,上面的 fun3 方法,需要看多线程调用时,锁定的是否是同一个对象,如果是,那么会互斥访问。

3. synchronized的底层实现

3.1. 同步方法

在方法的标志位加上 ACC_SYNCHRONIZED,当线程执行方法时,会看方法上是否有这个标志位,有的话,则去申请对象锁,如果申请到了对象锁,那么执行互斥方法,其他线程此时若再申请对象锁,则会被阻塞,直到拥有对象锁的线程退出同步方法,释放了锁。

3.2. 同步块

通过在同步块的开始插入 monitorenter,以及同步块结束和异常处理结束处插入 monitorexit 指令实现互斥访问。

3.3. 对象锁

java规定,任何对象都有一个monitor监视器。获取对象锁,就是拥有monitor。java的对象头,保存了锁的信息(是否是偏向锁,锁标志位)。

  1. 对象头的结构

    • 每个Java对象都有一个头部结构,一般包含两个部分,MarkWord和Klass Word,但对于数组对象,还多了一个数组长度。
    • Mark Word保存锁的信息,对象hashCode,分代年龄等信息, Klass Word保存的是指向Class类型的指针。
    • 64位的机器上,MW占用8B,KW占用8B(如果有指针压缩,会占用4B)。数组长度是4B。
  2. monitor对象
    JVM中的同步是基于进入与退出监视器对象(也叫管程对象,Monitor)来实现的,每个Java对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建并销毁。Monitor对象是由C++来实现的。

  3. 线程加锁的过程
    a) 当一个线程访问同步方法或同步块时,它先尝试获取对象锁,
    b) 如果对象锁没有被别的线程锁定,那么加锁【加锁过程,CAS】, 访问同步区的代码,
    c) 如果对象锁已经被别的线程锁定了,那么当前线程进入等待状态【这里应该是进入了一个等待队列】。

当多个线程同时访问一段同步代码时,这些线程会被放到一个EntryList集合中,处于阻塞状态的线程都会被放到该列表中。接下来,当线程获取到对象的Monitor时,Monitor是依赖于底层OS的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其他线程就无法再获取到该mutex。

如果线程调用了wait方法,那么该线程就会释放掉所持有的mutex,并且该线程会进入到WaitSet集合(等待集合,Object类wait方法java文档有说到)中,等待下一次被其他线程调用notify/notifyAll唤醒。如果当前线程顺利执行完毕方法,那么它也会释放掉所持有的mutex。

总结:同步锁的这种实现方式中,因为Monitor是依赖于底层操作系统的实现,这样就存在用户态与内核态的切换,所以会增加性能开销。(说明:正常执行的java线程,它是在用户太执行的,但是如果进入到阻塞-block, 等待-wait状态,那么是需要调用OS提供的底层API进行操作的,所以会切换到内核态,相反,如果线程被唤醒,或者是中断,那么线程会从内核态切换到用户态继续执行)

通过对象互斥锁的概念来保证共享数据操作的完整性。每个对象都对应于一个可称之为【互斥锁】的标记,这个标记用于保证在任何时刻,只有一个线程访问该对象。

那些处于EntryList 与 WaitSet中的线程均处于阻塞状态(不对吧?一个是Wait, 一个是Block呀),阻塞操作是由操作系统来完成的,在Linux下是通过pthread_mutex_lock函数来实现的。线程被阻塞后便会进入到内核调度状态,这会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

解决上述问题的办法便是自旋(Spin),其原理是:当发生对Monitor对象的争用时,若Owner能够在很多的时间内释放掉锁,则那些正在争用的线程就可以稍微等待一下(即所谓的自旋),在Owner线程释放掉锁之后,争用线程可能会立刻获取到锁,从而避免了系统阻塞。不过,当Owner运行的时间,超过了临界值后,争用线程自旋一段时间后依然无法获取到锁,这时争用的线程则会停止自旋而进入到阻塞状态。所以总体的思想是:先自旋,不成功再阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码块来说,有极大的性能提升。显然,自旋在多处理器(多核心)上才有意义。

  1. 锁升级的过程:
    a) 升级顺序:偏向锁 > 轻量级锁 > 重量级锁

  2. 互斥锁的属性

属性 意义
PTHREAD_MUTEX_TIMED_NP 这是是缺省值,也就是普通锁。当一个线程加锁后,其余请求加锁的线程将会形成一个等待队列,并且在解锁后按照优先级获取到锁。这种策略可以确保资源的分配的公平性。
PTHREAD_MUTEX_RECURSIVE_NP 嵌套锁,允许一个线程对同一个锁成功获取多次,并通过unlock解锁。如果是不同线程请求,则在加锁线程解锁时重新进行竞争。【Java可重入锁】
PTHREAD_MUTEX_ERRORCHECK_NP 检错锁,如果一个线程请求同一个锁,则返回EDEADLK,否则与PTHREAD_MUTEX_TIMED_NP 类型动作相同,这样就保证了当不允许多次加锁时不会出现最简单情况下的死锁。
PTHREAD_MUTEX_ADAPTIVE_NP 适应锁,动作最简单的锁类型,仅仅等待解锁后重新竞争。(不考虑线程优先级)

也有man页面 (libc/Mutexes.html),把它划分成以下四个:
PTHREAD_MUTEX_DEFAULT
PTHREAD_MUTEX_RECURSIVE
PTHREAD_MUTEX_ERRORCHECK
PTHREAD_MUTEX_NORMAL

3.4. Object的wait, notify, notifyAll方法

Object类的这三个方法必须是在同步块中调用,也就是要求调用这几个方法的线程,必须获得同步对象的锁(Monitor对象)。

可以重点看下这三个方法的Java文档,里面的信息比较全。

  • 调用Object #wait #notify #notifyAll 之前它必须是持有当前对象的monitor
  • 当前线程调用wait之后,它会释放monitor (对比Thread #sleep, 线程不会释放拥有的monitor),然后进入到等待状态,把自己放到该对象的等待集合里(wait set)
  • 当线程调用了wait后进入到等待状态时,它就可以等待其他线程调用相同对象的notify或notifyAll方法来使得自己被唤醒
  • 一旦这个线程被其他线程唤醒后,该线程就会与其他线程一同竞争这个对象的锁(公平竞争);只有当该线程获取到了这个对象的锁之后,线程才能往下执行(即使是timeout,或者被别人中断,抛出异常)
  • 当调用notify方法时,它会随机唤醒该对象等待集合(wait set)中的任意一个线程,当某个线程被唤醒后,它就会与其他线程一同竞争对象的锁
  • 被notify唤醒的线程,不是说就能立马执行,它也要先获取对象的monitor锁,在竞争monitor的方式上,跟其他线程没有任何优势或者劣势 【这里线程A调用obj的notify方法,唤醒了线程B,因为A已经拥有了obj的monitor对象,所以B唤醒后,它先是变成Runnable状态,等到A释放了monitor锁,B再去竞争】
  • 当调用对象的notifyAll方法时,它会唤醒该对象等待集合(wait set)中的所有线程,这些线程被唤醒后,又会开始竞争对象的锁
  • 在某一时刻,只有唯一一个线程可以拥有对象的锁
  • wait 通常的是配合while循环使用, 因为被notify唤醒后,它会再去获取monitor,如果获取到了,那么应该继续判断条件是否满足,因为wait之前已经判断过的条件,在被唤醒之后有可能不成立了,所以需要再次判断一下;还有文档里说有一种是虚幻的唤醒,必须要通过while循环来做判断
  • wait 和 notify都是native方法,也就是说,要调用本地OS提供的API才能操作线程 (因此,这里会有用户态到内核态的一个转换)
  • 若不在同步方法或同步块里调用对象的wait、notify、notifyAll方法,会抛出异常:IllegalMonitorStateException

注:wait有3个重载写法,每个文档都有些许不同,最好都读一遍。

3.4.1. Object #wait 和 Thread.sleep的关系

相同点:

  1. 从行为上看,wait方法和sleep方法都可以让当前执行线程退出执行,让出CPU。

不同点:

  1. 从线程状态变化上看,wait方法执行完成后,线程由Runnable进入Blocked的状态。而sleep方法,线程状态由Runnable进入TIMED_WAITING状态。
    【可以通过Thread类的State枚举类型的java文档来验证】
  2. 在调用Object #wait方法前,线程必须要持有被调用对象的锁,当调用wait方法后,线程必须释放掉该对象的锁(monitor)。
    而在调用Thread.sleep方法时,线程是不会释放掉对象的锁的。

3.4.2. wait与notify的实例

编写一个多线程程序,实现这样一个目标:MyTest1.java

  1. 存在一个对象,该对象有一个int类型的成员变量counter,该成员变量的初始值为0
  2. 创建两个线程,其中一个线程对该对象的成员变量counter增1,另外一个线程对该对象的成员变量减1
  3. 输出该对象的成员变量counter每次变化后的值
  4. 最终输出的结果应为:1010101010…

思考:换成4个线程,两个加1,两个减1的线程,结果还对吗,应该怎么写?

MyTest1 可以这么写

public class MyTest1 {
int counter;
public synchronized void increment() {
while (counter != 0) { // 两个线程的情况,用if结果似乎也对
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
counter++;
System.out.print(counter);
TimeUtils.sleep(500);
this.notify();
}

public synchronized void decrement() {
while (counter != 1) { // 两个线程的情况,用if结果似乎也对
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
counter--;
System.out.print(counter);
TimeUtils.sleep(500);
this.notify();
}

public static void main(String[] args) throws InterruptedException {
MyTest1 obj = new MyTest1();
Runnable incrRun = ()-> {
for (int i = 0; i < 10; i++) {
obj.increment();
}
};
Runnable decrRun = ()-> {
for (int i = 0; i < 10; i++) {
obj.decrement();
}
};
new Thread(incrRun).start();
new Thread(decrRun).start();
}
}

注意的是:这里因为只有两个线程竞争monitor,所以一个线程调用notify就可以唤醒另外一个线程了。同理,之所以用if也可以,是因为一个线程被唤醒之后,counter的变化只能是由另外一对应的线程操作引起的,这样的话,wait之前判断的条件仍然有效(也就是不满足wait条件了,可以继续往下执行,不需要再判断if条件)。

换成4个线程或者更多线程的写法:

public class MyTest1 {
int counter;
public synchronized void increment() {
while (counter != 0) {
try {
System.out.println(Thread.currentThread().getName() + " waiting...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
counter++;
System.out.println(counter);
TimeUtils.sleep(500);
this.notifyAll(); // 不能用 this.notify();
}

public synchronized void decrement() {
while (counter == 0) {
try {
System.out.println(Thread.currentThread().getName() + " waiting...");
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
counter--;
System.out.println(counter);
TimeUtils.sleep(500);
this.notifyAll(); // 不能用 this.notify();
}

public static void main(String[] args) throws InterruptedException {
MyTest1 obj = new MyTest1();
Runnable incrRun = ()-> {
for (int i = 0; i < 10; i++) {
obj.increment();
}
};
Runnable decrRun = ()-> {
for (int i = 0; i < 10; i++) {
obj.decrement();
}
};
int num = 4;
for (int i = 1; i <= num; i++) {
new Thread(incrRun, "I" + i).start();
new Thread(decrRun, "D" + i).start();
}
}
}

与上面的两个线程相比较,有两点需要注意:
第一,调用wait时,要配合while循环,因为线程从wait醒来后,它之前判断的条件,有可能已经不成立了,这时候就不能继续往下执行,而是要再判断一次条件;
第二,要用notifyAll,而不是notify,因为后者只随机唤醒其中一个线程,如果唤醒的是同类线程(都是加1,或者减1),那么结果还是继续wait。可能造成永久等待。

问答:

  1. 什么时候应该使用指针压缩?(默认情况就是开启了指针压缩)
    当最大堆内存【通过-Xmx指定】小于32G时,应该开启指针压缩【-XX:+UseCompressedOops】以提高性能。

4. 参考

  1. What is -XX:+UseCompressedOops in 64 bit JVM
  2. Java多线程–Monitor对象(一)_非淡泊无以明志,非宁静无以致远-CSDN博客

文章作者: IT神助攻
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 IT神助攻 !
  目录