7

浅谈自旋锁和 JVM 对锁的优化

 1 year ago
source link: https://blog.51cto.com/u_15723831/5703611
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

浅谈自旋锁和 JVM 对锁的优化

精选 原创

CRMEB众邦科技 2022-09-22 15:41:36 ©著作权

文章标签 自旋锁 自适应 加锁 jvm 文章分类 PHP 前端开发 阅读数174

先上图

浅谈自旋锁和 JVM 对锁的优化_自适应

由此可见,非自旋锁如果拿不到锁会把线程阻塞,直到被唤醒;自旋锁拿不到锁会一直尝试

为什么要这样?

阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。

在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。

用一句话总结自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。

AtomicLong 的实现

getAndIncrement 方法

public final long getAndIncrement() {
return unsafe.getAndAddLong(this, valueOffset, 1L);
}
复制代码
public final long getAndAddLong(Object o, long offset, long delta) {
long v;
do {
v = getLongVolatile(o, offset);
//如果修改过程中遇到其他线程竞争导致没修改成功,死循环,直到修改成功为止
} while (!compareAndSwapLong(o, offset, v, v + delta));
return v;
}
复制代码
package com.reflect;

import java.util.concurrent.atomic.AtomicReference;

class ReentrantSpinLock {
private AtomicReference<Thread> owner = new AtomicReference<>();
private int count = 0;

public void lock() {
Thread t = Thread.currentThread();
if (t == owner.get()) {
++count;
return;
}
while (!owner.compareAndSet(null, t)) {
System.out.println("自旋了");
}
}

public void unlock() {
Thread t = Thread.currentThread();
if (t == owner.get()) {
if (count > 0) {
--count;
} else {
owner.set(null);
}
}
}

public static void main(String[] args) {
ReentrantSpinLock spinLock = new ReentrantSpinLock();
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始尝试获 取自旋锁");
spinLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "获取到 了自旋锁");
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
spinLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了 了自旋锁");
}
}
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
复制代码

很多 "自旋了",说明自旋期间 CPU 依然在不停运转

虽然避免了线程切换的开销,但是在避免线程切换开销的同时带来新的开销:不停尝试获取锁,如果这个锁一直不能被释放那么这种尝试知识无用的尝试,浪费处理器资源,就是说一开始自旋锁开销低于线程切换,但是随着时间增加,这种开销后期甚至超过线程切换的开销,得不偿失

  • 并发不是特别高的场景
  • 临界区比较短小的情况,利用避免线程切换提高效率

如果临界区很大,线程拿到锁很久才释放,那自旋会一直占用 CPU 但无法拿到锁,浪费资源

JVM 对锁做了哪些优化?

相比于 JDK 1.5,在 JDK 1.6 中 HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。有了这些优化措施后,synchronized 锁的性能得到了大幅提高,下面我们分别介绍这些具体的优化。

自适应的自旋锁

在 JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。自旋的持续时间是变化的,自旋锁变 “聪明” 了。比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

public class Person {
private String name;
private int age;

public Person(String personName, int personAge) {
name = personName;
age = personAge;
}

public Person(Person p) {
this(p.getName(), p.getAge());
}

public String getName() {
return name;
}

public int getAge() {
return age;
}
}

class Employee {
private Person person;

public Person getPerson() {
return new Person(person);
}

public void printEmployeeDetail(Employee emp) {
Person person = emp.getPerson();
System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
}
}
复制代码

在这段代码中,我们看到下方的 Employee 类中的 getPerson () 方法,这个方法中使用了类里面的 person 对象,并且新建一个和它属性完全相同的新的 person 对象,目的是防止方法调用者修改原来的 person 对象。但是在这个例子中,其实是没有任何必要新建对象的,因为我们的 printEmployeeDetail () 方法没有对这个对象做出任何的修改,仅仅是打印,既然如此,我们其实可以直接打印最开始的 person 对象,而无须新建一个新的。

如果编译器可以确定最开始的 person 对象不会被修改的话,它可能会优化并且消除这个新建 person 的过程。根据这样的思想,接下来我们就来举一个锁消除的例子,,经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

例如,我们的 StringBuffffer 的 append 方法如下所示:

@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
复制代码

从代码中可以看出,这个方法是被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。

但是在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么我们的编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。

释放了锁,紧接着什么都没做,又重新获取锁

public void lockCoarsening() {
synchronized (this) {
}
synchronized (this) {
}
synchronized (this) {
}
}
复制代码

那么其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样做的好处在于在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。

不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。如果在循环中我们也这样做,如代码所示:

for (int i = 0; i < 1000; i++) {
synchronized (this) {
}
}
复制代码

也就是我们在第一次循环的开始,就开始扩大同步区域并持有锁,直到最后一次循环结束,才结束同步代码块释放锁的话,这就会导致其他线程长时间无法获得锁。所以,这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。

锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能

偏向锁 / 轻量级锁 / 重量级锁

这三种锁是特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态

  • 偏向锁

对于偏向锁而言,它的思想是如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只要打个标记就行了。一个对象在被初始化后,如果还没有任何线程来获取它的锁时,它就是可偏向的,当有第一个线程来访问它尝试获取锁的时候,它就记录下来这个线程,如果后面尝试获取锁的线程正是这个偏向锁的拥有者,就可以直接获取锁,开销很小。

  • 轻量级锁

JVM 的开发者发现在很多情况下,synchronized 中的代码块是被多个线程交替执行的,也就是说,并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决。这种情况下,重量级锁是没必要的。轻量级锁指当锁原来是偏向锁的时候,被另一个线程所访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的方式尝试获取锁,不会阻塞

  • 重量级锁

这种锁利用操作系统的同步机制实现,所以开销比较大。当多个线程直接有实际竞争,并且锁竞争时间比较长的时候,此时偏向锁和轻量级锁都不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。

浅谈自旋锁和 JVM 对锁的优化_自适应_02

JVM 默认会优先使用偏向锁,如果有必要的话才逐步升级,这大幅提高了锁的性能

完整附件: ​ ​点击此处下载附件​


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK