多线程高级JUC(2)


一、锁

1、悲观锁

synchronized (this){
    // 业务代码
}

// 需要保证多线程使用同一个ReentrantLock
ReentrantLock lock = new ReentrantLock();

lock.lock();
try{
    // 业务代码

}finally {
    lock.unlock();
}

2、乐观锁

适合读操作多的场景,不加锁的特点能够使其并发度大大提升,无锁算法。

乐观锁一般有两种实现方式:

  • 采用 version 版本号机制
  • CAS (Compare and swap) 比较并交换
// 需要保证多线程使用同一个 AtomicInteger
AtomicInteger atomicInteger = new AtomicInteger();

atomicInteger.incrementAndGet();

3、自旋锁

(1)介绍

自旋锁,即当锁被其他线程持有时,它并不会放弃 CPU 时间片,而是通过自旋(循环获取)等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止。

非自旋锁,非自旋锁和自旋锁是完全不一样的,如果它发现此时获取不到锁,它就把自己的线程切换状态,让线程休眠,然后 CPU 就可以在这段时间去做很多其他的事情,直到之前持有这把锁的线程释放了锁,于是 CPU 再把之前的线程恢复回来,让这个线程再去尝试获取这把锁。如果再次失败,就再次让线程休眠,如果成功,一样可以成功获取到同步资源的锁。

image-20220705135307899

非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试

(2)优点

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

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

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

(3)缺点

它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失。

(4)应用场景

自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。

可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。

(5)案例

在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现

看一个 AtomicLong 的实现,里面有一个 getAndIncrement 方法,源码如下

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

可以看到它调用了一个 unsafe.getAndAddLong,所以我们再来看这个方法

public final long getAndAddLong (Object var1,long var2, long var4){
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));

    return var6;
}

这里的 do-while 循环就是一个自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止

4、可重入锁

可重入锁(递归锁),指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的。

synchronized 和 ReentrantLock 都是可重入锁。

  • synchronized 隐式锁:直接加锁即可
  • ReentrantLock 显式锁:加了几次锁就需要释放几次锁

可重入锁的意义之一在于防止死锁

演示

public class Test4 {

    private Object object = new Object();

    public void test(){
        synchronized (object){
            synchronized (object){
                System.out.println("111");
            }
        }
    }

    public static void main(String[] args) {
        Test4 test4 = new Test4();
        test4.test();
    }
}

synchronized 如何实现可重入锁?

这个问题需要溯源到 java 的 c++ 实现了,java 中每个对象都拥有一个锁计数器和一个指向持有该锁的线程的指针。

当执行 monitorenter 时,如果目标锁对象的计数为零,那么说明他没有被其他线程所持有,Java 虚拟机会将该锁对象持有的线程设置为当前线程,计数加一。

在目标对象的计数不为零的情况下,如果锁对象是当前线程,则 Java 虚拟机将其计数器加一,否则需要等待,直至持有该锁的线程释放锁。

当执行 monitorexit 时, Java 虚拟机则需要将锁对象的计数器减一,计数器为零代表锁已被释放。

其实 ReentrantLock 实现可重入锁的原理也是差不多的

首先,我们已知ReentrantLock是可重入锁,那么它的可重入是怎么实现的呢,源码中有这样一段代码。

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

if (current == getExclusiveOwnerThread()) 这一段就是进行可重入锁的判断,因此可以得知,可重入锁就是在加锁时判断当前线程是否是已经获取到锁的线程,如果是的话,锁的次数会+1。需要注意的是既然多次加锁,就需要多次释放锁。

5、公平锁和非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU 唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

// 非公平锁
synchronized

// 非公平锁
ReentrantLock fairSync = new ReentrantLock();

// 公平锁
ReentrantLock nonfairSync = new ReentrantLock(true);

公平锁和非公平锁演示

class Ticket{
    private int number = 30;
    //ReentrantLock lock = new ReentrantLock();
    ReentrantLock lock = new ReentrantLock(true);

    public void sale(){
        lock.lock();
        try{
            if(number > 0){
                System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

public class SaleTicketDemo{
    public static void main(String[] args){
        Ticket ticket = new Ticket();

        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"a").start();
        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"b").start();
        new Thread(() -> { for (int i = 0; i <35; i++)  ticket.sale(); },"c").start();
    }
}

二、死锁

1、介绍

死锁是指两个或以上的线程相互争夺资源而造成的一种相互等待的现象,若无外力干涉则会一直等待下去。

如果系统资源充足,进程的资源请求都能得到满足,死锁出现的可能性极低。

image-20220706211753480

2、死锁演示代码

final Object objectA = new Object();
final Object objectB = new Object();

new Thread(()->{
    synchronized (objectA){
        System.out.println("t1线程获取到A锁,尝试去获取B锁");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (objectB){
            System.out.println("t1获取到B锁");
        }
    }
}, "t1").start();

new Thread(()->{
    synchronized (objectB){
        System.out.println("t2获取到B锁,尝试去获取A锁");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (objectA){
            System.out.println("t2获取到A锁");
        }
    }
}, "t2").start();

3、死锁排查

(1)纯命令

jps -l

jps 是 java 提供的一个用来显示当前所有 java 进程的 pid 的命令。

java 的每一个程序,均独占一个 java 虚拟机实例,且都是一个独立的进程。每个进程都有自己的 id

输出如下

21184 org.jetbrains.jps.cmdline.Launcher
16084
5060 com.rewind.code.test.Test4
6036 sun.tools.jps.Jps
4300 org.jetbrains.idea.maven.server.RemoteMavenServer36

然后用 jstack 命令查看进程情况

jstack 进程号

jstack命令用于打印指定Java进程、核心文件或远程调试服务器的Java线程的Java堆栈跟踪信息,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。

输出如下

"t2":
        at com.rewind.code.test.Test4.lambda$main$1(Test4.java:49)
        - waiting to lock <0x000000076e239808> (a java.lang.Object)
        - locked <0x000000076e239818> (a java.lang.Object)
        at com.rewind.code.test.Test4$$Lambda$2/1637070917.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)
"t1":
        at com.rewind.code.test.Test4.lambda$main$0(Test4.java:35)
        - waiting to lock <0x000000076e239818> (a java.lang.Object)
        - locked <0x000000076e239808> (a java.lang.Object)
        at com.rewind.code.test.Test4$$Lambda$1/521645586.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.

(2)图形化方式

同样是java自带的命令

jconsole

image-20220706214653849

三、线程中断

1、中断相关

如何中断一个运行中的线程?

如何停止一个运行中的线程?

中断和停止的区别?

首先,一个线程不应该由其他线程中断或停止,而是应该由线程自行停止。所以,Thread的以下方法均被废弃

/**
 * 作用: 强制线程停止
 * 这种方法本质上是不安全的。 
 * 使用Thread.stop停止线程可以解锁所有已锁定的监视器(由于未ThreadDeath ThreadDeath异常
 * 在堆栈中ThreadDeath的自然结果)。 如果先前受这些监视器保护的任何对象处于不一致的状态,
 * 则损坏的对象将变得对其他线程可见,可能导致任意行为。 stop许多用途应该被替换为只是修改
 * 一些变量以指示目标线程应该停止运行的代码。 目标线程应该定期检查此变量,如果变量表示要停止运行,
 * 则以有序方式从其运行方法返回。 如果目标线程长时间等待(例如,在interrupt变量上),
 * 则应该使用interrupt方法来中断等待。
 */
public final void stop(){}

/**
 * 作用: 暂停/挂起线程
 * 这种方法已被弃用,因为它本身就是死锁的。 如果目标线程在挂起时保护关键系统资源的监视器上的锁定,
 * 则在目标线程恢复之前,线程不能访问该资源。 如果要恢复目标线程的线程在调用resume之前尝试锁定
 * 此监视器, resume导致死锁。 这种僵局通常表现为“冻结”过程。
 */
public final void suspend(){}

/**
 * 作用: 恢复线程执行
 * 该方法仅用于与suspend()一起使用,因为它是死锁倾向的,因此已被弃用。
 */
public final void resume(){}

其次,在 Java 中没有办法立即停止一个线程,然而停止线程却又尤为重要,如停止一个耗时操作。因此,java 提供了一种用于停止线程的协商机制 — 中断,即中断标识协商机制

中断只是一种协商机制,Java 没有给中断增加任何语法,中断过程完全由程序员自己实现。

若要中断一个线程,需要手动调用它的 interrupt() 方法,该方法也仅仅是将线程对象的中断标识改为 true;

接着需要在目标线程中不断检测当前线程的标识位,如果为 true,表示别的线程请求中断该线程,接下来就需要自行实现线程停止了。

每个线程都有一个中断标识位,用于表示线程是否被中断;该标识位为 true 表示中断,false 表示未中断;通过调用线程对象的 interrupt() 方法将该线程的标识位设置为 true;可以在别的线程中调用,也可以在自己的线程中调用。

注意:中断正常活动的线程,只会将中断标志设置为 true;如果线程处于被阻塞状态(例如处于 sleep,wait,join 等状态),在别的线程调用当前线程对象的 interrup 方法,那么线程将立即退出被阻塞状态,并抛出 interruptedException 异常。

中断不活动的线程不会产生任何影响,线程退出或结束时,会自动将中断标志设置为false(jdk17不会自动设置)

2、线程中断相关方法

// 设置线程的中断状态为 true,发起协商,不会直接中断线程
public void interrupt(){}

// 返回当前线程的中断状态
public boolean isInterrupted(){}

// 返回当前线程的中断状态,并清除中断状态(将中断状态设置为 false)
public static boolean interrupted(){}

3、如何停止中断的线程

(1)通过Volatile

如果不加 volatile 关键字,则 t1 线程读取不到,isStop 的改变

static volatile Boolean isStop = false;

@Test
public void test7() throws InterruptedException {

    new Thread(()->{
        System.out.println("t1 start...");
        while (true){
            if (isStop){
                System.out.println("t1 线程中断");
                break;
            }
        }
    }, "t1").start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        System.out.println("请求中断 t1 线程");
        isStop = true;
    }, "t2").start();

    TimeUnit.SECONDS.sleep(10);
}

(2)通过 AtomicBoolean

static AtomicBoolean isStop = new AtomicBoolean(false);

@Test
public void test7() throws InterruptedException {

    new Thread(()->{
        System.out.println("t1 start...");
        while (true){
            if (isStop.get()){
                System.out.println("t1 线程中断");
                break;
            }
        }
    }, "t1").start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        System.out.println("请求中断 t1 线程");
        isStop.set(true);
    }, "t2").start();

    TimeUnit.SECONDS.sleep(10);
}

(3)通过Thread的API

@Test
public void test7() throws InterruptedException {

    Thread t1 = new Thread(() -> {
        System.out.println("t1 start...");
        while (true) {
            if (Thread.currentThread().isInterrupted()){
                System.out.println("t1 线程中断");
                break;
            }
        }
    }, "t1");
    t1.start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        System.out.println("请求中断 t1 线程");
        t1.interrupt();
    }, "t2").start();

    TimeUnit.SECONDS.sleep(10);
}

4、线程等待导致死循环

当调用 t1.interrupt(); 的时候,如果该线程阻塞的调用 wait()sleep() 方法,那么该线程的状态将被清除(重新设置为 false),并且抛出 InterruptedException

其实可以直接理解为 wait()sleep() 状态下的线程无法被中断

public void test8() throws InterruptedException {
    Thread t1 = new Thread(() -> {

        while (true){

            if (Thread.currentThread().isInterrupted()){
                System.out.println("t1中断");
                break;
            }

            System.out.println("正常运行");

            try {
                TimeUnit.MILLISECONDS.sleep(100);
            } catch (InterruptedException e) {

                //需要在这里重新中断线程才能正常退出死循环
                //Thread.currentThread().interrupt();

                e.printStackTrace();
            }
        }


    }, "t1");

    t1.start();

    TimeUnit.SECONDS.sleep(1);
    t1.interrupt();

    TimeUnit.SECONDS.sleep(5);
}

以上代码调用 t1.interrupt(); 的时候并不会导致 t1 线程中断,而是死循环,不会停止。

线程不会中断的原因如下:

  • 主线程调用 t1.interrupt(); 将 t1 的中断标志设置为 true;
  • 此时 t1 线程是处于 sleep 状态,所以中断标志被重新设置为 false,并抛出中断异常
  • 便无法退出循环

解决方式是在捕获到 InterruptedException 异常的时候,手动在中断一次线程

5、静态方法interrupted

返回当前线程的中断状态,并清除中断状态(将中断状态设置为 false)

如果线程被中断后,连续两次调用 Thread.interrupted() 方法,将得到不同的值,除非2次之间线程再次被中断

@Test
public void test9(){

    System.out.println(Thread.interrupted());
    System.out.println(Thread.interrupted());

    Thread.currentThread().interrupt();
    System.out.println("线程中断...");

    System.out.println(Thread.interrupted());
    System.out.println(Thread.interrupted());
}

输出

false
false
线程中断...
true
false

四、LockSupport

1、介绍

LockSupport 是用来创建锁和其他同步类的基本线程阻塞原语。即对线程等待和唤醒机制的优化。

该类与使用他的每个线程关联一个许可证(在 Semaphore类的意义上)。如果许可证可用,将立即返回 park,并在此过程中消费;否则会阻止。如果没有许可证,可通过 unpark() 获取许可证。(与 Semaphore 类不同,许可证不会累加,至多有一个)。

LockSport 使用 Permit (许可)的概念来做到阻塞和唤醒线程,每个线程都有一个许可。许可累加上限是1。

线程默认是没有 Permit 的,所以一开始调用 park() 方法就会阻塞线程,直到别的线程调用 unpark() 为阻塞的线程发放 Permit 时,被阻塞的线程才会被唤醒。

如果先执行 unpark() 给指定线程发放通行证,则之后当线程调用 park() 时,因为已拥有通行证,便不再阻塞线程

// 阻塞线程
LockSupport.park();

// 解除指定线程的阻塞,参数为指定线程
LockSupport.unpark(t1);

2、三种线程等待唤醒方法

(1)Object 的 wait 和 notify

lock.notify(); 并不是直接 唤醒线程,而是让线程进入可运行状态,等待获取锁

@Test
public void test10() throws InterruptedException {

    Object lock = new Object();

    new Thread(()->{
        synchronized (lock){
            System.out.println("t1 start...");
            try {
                lock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1 end...");
        }
    }, "t1").start();

    new Thread(()->{
        synchronized (lock){
            lock.notify();
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("唤醒t1");
        }
    }).start();

    TimeUnit.SECONDS.sleep(3);
}

输出

t1 start...
唤醒t1
t1 end...

限制条件

  • wait()notify() 方法必须在 synchronized 方法或代码块中,否则会抛出异常。
  • 如果先调用 notify() 在调用 wait() ,调用 wait() 的线程将不会被唤醒

(2)Lock.newContition()

关键字 synchronizedwait()/notify() 这两个方法一起使用可以实现等待/通知模式, Lock锁的newContition() 方法返回 Condition 对象,Condition类也可以实现等待/通知模式。

notify()通知时,JVM会随机唤醒某个等待的线程, 使用Condition类可以进行选择性通知, Condition比较常用的两个方法:

await() 会使当前线程等待,同时会释放锁,当其他线程调用signal()时,线程会重新获得锁并继续执行。

signal() 用于唤醒一个等待的线程。

注意:在调用 Conditionawait()/signal() 方法前,也需要线程持有相关的Lock锁,调用 await()后线程会释放这个锁,在 singal() 调用后会从当前 Condition对象的等待队列中,唤醒 一个线程,唤醒的线程尝试获得锁, 一旦获得锁成功就继续执行。

@Test
public void test11() throws InterruptedException {
    ReentrantLock lock = new ReentrantLock();
    Condition condition = lock.newCondition();

    new Thread(()->{
        lock.lock();

        try {
            System.out.println("t1 start");
            condition.await();
            System.out.println("t1 恢复");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }, "t1").start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        lock.lock();
        try {
            System.out.println("通知 t1 唤醒");
            condition.signal();
        }finally {
            lock.unlock();
        }
    }, "t2").start();

    TimeUnit.SECONDS.sleep(3);
}

限制条件

  • condition.await();condition.signal(); 也必须在 lock 的锁块中,否则会抛出异常
  • condition.await();condition.signal(); 更换执行顺序后,会导致 t1 无法被唤醒

(3)LockSupport

@Test
public void test12() throws InterruptedException {


    Thread t1 = new Thread(() -> {
        System.out.println("t1 start...");
        LockSupport.park();
        System.out.println("t1 恢复...");
    }, "t1");
    t1.start();

    TimeUnit.SECONDS.sleep(3);

    new Thread(()->{
        System.out.println("唤醒 t1");
        LockSupport.unpark(t1);
    }).start();

    TimeUnit.SECONDS.sleep(3);
}

结果

t1 start...
唤醒 t1
t1 恢复...

示例2 – 更换执行顺序

@Test
public void test12() throws InterruptedException {


    Thread t1 = new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("t1 阻塞...");
        LockSupport.park();
        System.out.println("t1 恢复...");
    }, "t1");
    t1.start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        System.out.println("发放通行证 t1");
        LockSupport.unpark(t1);
    }).start();

    TimeUnit.SECONDS.sleep(3);
}

结果,LockSupport.park(); 不会导致阻塞,因为 t1 线程已有通行证

发放通行证 t1
t1 阻塞...
t1 恢复...

注意

LockSupport 解决了前面两种线程等待唤醒方法的限制条件,即:

  • 不需要在锁块中调用
  • park()unpark() 不会因为执行顺序的先后而导致线程无法唤醒

3、至多持有一个许可证

即使多次调用 unpark() 方法,指定线程也只会持有一个许可证,当调用一次 park() 方法后,许可证就会被消耗掉,再次调用 park() 时,便会一直被阻塞。

如下代码 ,线程 t1 的 System.out.println("t1 第二次恢复..."); 无法被执行

@Test
public void test12() throws InterruptedException {


    Thread t1 = new Thread(() -> {
        System.out.println("t1 阻塞...");
        LockSupport.park();
        System.out.println("t1 第一次恢复...");
        LockSupport.park();
        System.out.println("t1 第二次恢复...");
    }, "t1");
    t1.start();

    TimeUnit.SECONDS.sleep(1);

    new Thread(()->{
        System.out.println("发放通行证 t1");
        LockSupport.unpark(t1);
        LockSupport.unpark(t1);
    }).start();

    TimeUnit.SECONDS.sleep(3);
}

结果

t1 阻塞...
发放通行证 t1
t1 第一次恢复...

五、synchronized

1、锁的是什么-八锁问题

  • 阿里巴巴代码规范
    • 【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。
    • 说明:尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。

其实主要就是对象锁和类锁的区别,synchronized 加在静态方法上锁的是类,加在普通方法上锁的是对象。

class Phone{
    public static synchronized void sendEmail(){
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("-------------sendEmail");

    }
    public  synchronized void sendSMS(){//static
        System.out.println("-------------sendSMS");
    }
    public void hello(){
        System.out.println("-------------hello");
    }
}

/**
 * - 题目:谈谈你对多线程锁的理解,8锁案例说明
 * - 口诀:线程 操作 资源类
 * 1. 标准访问有ab两个线程,请问先打印邮件还是短信?邮件
 * 2. a里面故意停3秒?邮件
 * 3. 添加一个普通的hello方法,请问先打印邮件还是hello?hello
 * 4. 有两部手机,请问先打印邮件(这里有个3秒延迟)还是短信?短信
 * 5.有两个静态同步方法(synchroized前加static,3秒延迟也在),有1部手机,先打印邮件还是短信?邮件
 * 6.两个手机,有两个静态同步方法(synchroized前加static,3秒延迟也在),有1部手机,先打印邮件还是短信?邮件
 * 7.一个静态同步方法,一个普通同步方法,请问先打印邮件还是手机?短信
 * 8.两个手机,一个静态同步方法,一个普通同步方法,请问先打印邮件还是手机?短信
 */
public class lock8 {
    public static void main(String[] args) {
        Phone phone = new Phone();
        Phone phone2 = new Phone();
        new Thread(()->{
            phone.sendEmail();
        },"a").start();

        //暂停毫秒,保证a线程先启动
        try {TimeUnit.MILLISECONDS.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}

        new Thread(()->{
            phone.sendSMS();
        },"b").start();
    }
}
  • 1.2中

一个对象里面如果有多个synchronized方法,某一时刻内,只要一个线程去调用其中的一个synchronized法了,其他的线程都只能是等待,换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronize方法,锁的是当前对象this,被锁定后,其它的线程都不能 进入到当前对象的其他synchronized方法

  • 3中

hello并未和其他synchronized修饰的方法产生争抢

  • 4 中

锁在两个不同的对象/两个不同的资源上,不产生竞争条件

  • 5.6中

static+synchronized - 类锁 phone = new Phone();中 加到了左边的Phone上

对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部部手机,所有的普通同步方法用的都是同一把锁→实例对象本身。

对于静态同步方法,锁的是当前类的Class对象,如Phone,class唯一的一个模板。

对于同步方法块,锁的是synchronized括号内的对象。synchronized(o)

  • 7.8中

一个加了对象锁,一个加了类锁,不产生竞争条件

总结

作用域实例方法,当前实例加锁,进入同步代码块前要获得当前实例的锁。

作用于代码块,对括号里配置的对象加锁。

作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁

2、字节码角度分析

public class Test4 {

    public void m1(){
        synchronized (this){
            System.out.println("同步代码块...");
        }
    }

    public synchronized void m2(){
        System.out.println("普通同步代码...");
    }

    public static synchronized void m3(){
        System.out.println("静态同步代码块...");
    }

    public static void main(String[] args) {

    }
}

将以上代码通过 javap 命令反编译,输出一下字节码

  • 文件反编译javap -c ***.class文件反编译,-c表示对代码进行反汇编
  • 假如需要更多信息 javap -v ***.class ,-v即-verbose输出附加信息(包括行号、本地变量表、反汇编等详细信息)
{
  public com.rewind.code.test.Test4();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/rewind/code/test/Test4;

  public void m1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter                      // 加锁
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String 同步代码块...
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit                         // 正常情况下解锁代码        
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit                        // 异常情况下解锁
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 10: 0
        line 11: 4
        line 12: 12
        line 13: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/rewind/code/test/Test4;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/rewind/code/test/Test4, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public synchronized void m2();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED        // ACC_SYNCHRONIZED标记该方法加对象锁
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String 普通同步代码...
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 16: 0
        line 17: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/rewind/code/test/Test4;

  public static synchronized void m3();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED    //ACC_STATIC, ACC_SYNCHRONIZED标记改方法添加类锁
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #6                  // String 静态同步代码块...
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 20: 0
        line 21: 8

}
SourceFile: "Test4.java"

同步代码块

  • synchronized同步代码块,实现使用的是moniterenter和moniterexit指令(moniterexit可能有两个)

  • 那一定是一个enter两个exit吗?(不一样,如果主动throw一个RuntimeException,发现一个enter,一个exit,还有两个athrow)

普通同步方法

  • 调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置。如果设置了,执行线程会将先持有monitore然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor

静态同步方法

  • ACC_STATICACC_SYNCHRONIZED 访问标志区分该方法是否是静态同步方法。

六、volatile

1、两大特性

volatile 有两大特性:

  • 可见性:该线程修改的变量对其他线程立即可见

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。

  • 有序性:禁用指令重排

2、内存语义

volatile 内存语义:

  • 当写一个 volatile 变量时,JMM 会把线程对应的本地内存中的共享变量值立即刷新回主内存中,并发出通知
  • 当读一个 volatile 变量时,JMM 会把线程对应的本地内存设置为无效,重新回到主内存中读取最新的变量值。

3、内存屏障(重点)

volatile 凭什么可以保证可见性和有序性?—- 内存屏障(Memory Barrier)

内存屏障(也称内存栅栏,屏障指令等,是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前所有的读写操作都执行完了以后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实是一种 JVM 指令,Java 内存模型的重排序会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了 Java 内存模型中的可见性和有序性(禁重排),volatile 无法保证原子性。

  • 内存屏障之前的所有写操作都要回写到主内存
  • 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(可见性)

读写屏障

  • 写屏障(Store Memory Barrier):告诉处理器在写屏障之前将所有存储在缓存(store buffer)中的数据同步到主内存,也就是说当看到 Store 屏障指令,就必须把该指令之前所有的写入操作执行完毕才能继续往下执行。写指令之后插入写屏障,强制把工作内存中的数据刷回主内存。

  • 读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都必须在读屏障之后执行(禁止重排),也就是说在 Load 屏障指令之后就能够保证后面的读取数据指令一定能够读取到最新的数据。读指令之前插入读屏障,会让工作内存或 CPU 高速缓存中的缓存数据失效,并重新从主内存中读取。

image-20220726095229399

因此,重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。一句话,对一个 volatile 变量的写,先发生在后续任意对这个 volatile 变量的读,也叫写后读。

读写屏障相关可查看 Unsafe 类

// 读屏障
public native void loadFence();
// 写屏障
public native void storeFence();
// 混合屏障
public native void fullFence();

四大屏障插入策略

这里的实现在底层 c 或 c++ 源码中

  • 在每个 volatile 读操作后面插入一个 LoadLoad 屏障,用于禁止处理器把上面的 volatile 读和下面的普通读重排序
  • 在每个 volatile 读操作后面插入一个 LoadStore 屏障,用于禁止处理器把上面的 volatile 读和下面的普通读写排序
  • 在每个 volatile 写操作前面插入一个 StoreStore 屏障,保证在 volatile 写之前,其前面所有的普通读写操作都已经刷新到主内存
  • 在每个 volatile 写操作后面插入一个 StoreLoad 屏障,避免 volatile 写和之后可能存在的 volatile 读写重排序
屏障类型 指令示意 说明
LoadLoad Load1;LoadLoad;Load2 保证load1的读取操作在 load2 及后续的读取操作之前
StoreStore Store1;StoreStore;Store2 在store2及其之后的写操作之前,保证 store1 的写操作已经刷新到主内存
LoadStore Load1;LoadStore;Store2 在store2及其之后的写操作之前,保证 load1 已经读取完毕
StoreLoad Store1;StoreLoad;Load2 保证 store1 已经刷新到主内存之后,load2 及后续读操作才可执行

volatile 读执行顺序

image-20220726152755413

volatile 写执行顺序

image-20220726152937179

(1)指令重排规则

操作一 操作二:普通读写 操作二:volatile读 操作二:volatile写
普通读写 可重排 可重排 不可重排
volatile 读 不可重排 不可重排 不可重排
volatile 写 可重排 不可重排 不可重排
  • 当第一个操作是 volatile读时,不论第二个操作是什么,均不可重排。这个操作保证 volatile 读之后的操作不会被重排到 volatile 读之前
  • 当第二个操作是 volatile 写时,不论第一个操作时什么,均不可重排。这个操作保证volatile 写之前的操作不会被重排到 volatile 写之后
  • 当第一个操作是 volatile 写,并且第二个操作是 volatile读时,不可重排

4、可见性

案例看JVM 类加载的 JMM可见性

解析看JUC的JMM

5、不具备原子性

多线程操作同一变量时,会出现线程安全问题,即使加上 volatile 也还是会出现线程安全问题,除非加上 synchronized

int num = 0;

@Test
public void test13() throws InterruptedException {
    for (int i = 0; i < 100; i++) {
        new Thread(()->{
            for (int j = 0; j < 100; j++) {
                num++;
            }

        }).start();
    }

    Thread.sleep(5000);

    System.out.println(num);
}

当变量 num 不加任何关键字时,会出现线程安全问题,输出结果经常不是预期结果 10000

volatile int num = 0;

当变量 num 加上 volatile 关键字时,仍然会出现线程安全问题,只有加上 synchronized 才能避免

不保证原子性的原因

image-20220726174433988

即线程1从主内存读取变量后,线程2修改了该变量,线程1将工作内存的变量修改后,刷新回主内存,此时线程2对该变量的修改会被覆盖。

6、有序性(禁重排)

数据依赖性:若两个操作访问同一变量,且这两个操作中有一个是写操作,此时两个操作之间就存在依赖关系

  • 存在依赖关系的两条指令必须禁止重排序

编译器和处理器在重排序时,会遵守数据依赖性,不会改变存在依赖关系的两个操作的执行顺序,但不同处理器不同线程之间的数据性不会被编译器和处理器考虑,重排序只会作用于但处理器的单线程环境。

只是从单线程角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。

下面三种情况,只要重排序发生,结果就会改变:

名称 代码示例 说明
写后读 a = 1;
b = a;
写一个变量之后在读取
写后写 a = 1;
a = 2;
写一个变量后在写
读后写 a = b;
b = 1;
读一个变量之后在写

(1)指令重排序导致的结果错误

static int x = 0;
static int y = 0;
static int a = 0;
static int b = 0;

@Test
public void test17() throws InterruptedException {
    Thread one = new Thread(() -> {
        a = 1;
        x = b;
    });

    Thread two = new Thread(() -> {
        b = 1;
        y = a;
    });
    one.start();
    other.start();

    // 等待线程死亡后在往下执行
    one.join();
    other.join();
    System.out.println("(" + x + "," + y + ")");
}

很容易想到这段代码的运行结果可能为(1,0)、(0,1)或(1,1)

因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。

然而,这段代码的执行结果也可能是(0,0). 因为,在实际运行时,代码指令可能并不是严格按照代码语句顺序执行的。得到(0,0)结果的语句执行过程,如下图所示。值得注意的是,a=1和x=b这两个语句的赋值操作的顺序被颠倒了,或者说,发生了指令“重排序”(reordering)。(事实上,输出了这一结果,并不代表一定发生了指令重排序,内存可见性问题也会导致这样的输出,详见后文)

img

反复执行上面的实例代码,直到出现a=0且b=0的输出为止。结果说明,循环执行到第13830次时输出(0,0)。

大多数现代微处理器都会采用将指令乱序执行(out-of-order execution,简称OoOE或OOE)的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待3。通过乱序执行的技术,处理器可以大大提高执行效率。

除了处理器,常见的Java运行时环境的JIT编译器也会做指令重排序操作,即生成的机器指令与字节码指令顺序不一致。

7、使用场景

(1)读多写少的环境提高并发

public class Test4 {

    private volatile int value = 0;

    /**
     * 在读多写少的情况下
     * 读操作,加 synchronized 的话并发度会降低
     * 可以给变量加上 volatile 保证可见性,从而避免使用 synchronized 
     */
    public int getValue() {
        return value;
    }

    /**
     * 写操作,利用 synchronized 保证原子性
     */
    public synchronized int increment(){
        return value++;
    }
}

(2)线程间状态通知

public class Test4 {

    // 利用 volatile 可见性,保证某一线程的修改,其他线程可见
    private volatile static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        new Thread(()->{
            while (flag){
                // do ...
            }
        }).start();

        TimeUnit.SECONDS.sleep(3);

        new Thread(()->{
            flag = false;
        }).start();
    }
}

(3)双重检查锁(DCL)

double check lock

public class Singleton {
    private volatile static Singleton instance; //声明成 volatile
    private Singleton (){}

    public static Singleton getSingleton() {
        if (instance == null) {                         
            synchronized (Singleton.class) {
                if (instance == null) {    
                    // instance如果不加volatile,多线程环境下,由于重排序,
                    //该对象可能还未完成初始化就被其他线程读取
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

如果 instance 变量不加 volatile,它是有问题。主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

我们只需要将 instance 变量声明成 volatile 就可以了。

有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

七、CAS

1、CAS API

cpmpare and swap(比较并交换) ,和乐观锁相似

CAS 包含三个操作数—内存位置、预期原值、更新值:

  • 执行 CAS 时,将内存位置的值与预期原值(旧值)比较
    • 如果相匹配,那么系统自动将该位置的值更新为新值
    • 如果不匹配,那么不做任何操作,多线程同时执行 CAS 只有一个会成功,不成功并且进行重试的操作叫自旋
// 创建原子类并设置初始值为5
AtomicInteger atomicInteger = new AtomicInteger(5);

// 参数1:期望值, 参数2: 新值
// 将原子类当前的值和期望值比较,如果相等,证明没被其他线程修改,则将原子类的值设置为新值,如果不相等,则不作任何操作
boolean b = atomicInteger.compareAndSet(5, 6);
System.out.println(b + "----" + atomicInteger.get());//true----6

CAS 是 JDK 提供的非阻塞原子性操作,他通过硬件保证了比较-更新的原子性。

CAS 是一条 CPU 的原子指令(cmpxchg指令),不会造成所谓的数据不一致的问题,Unsafe 提供的 CAS 方法(如compareAndSwapXXXX)底层实现即为 cmpxchg

执行 cmpxchg 指令时,会判断当前系统是否为多核系统,如果是就给总线加锁,只有一个线程会对总线加锁成功,加锁成功后会执行 CAS 操作,也就是说 CAS 的原子性实际上是 CPU实现独占的,比起使用 synchronized 重量级锁,这里的排他时间更短,在多线程的情况下性能比较好。

2、自定义原子类

AtomicReference<Person> atomicReference = new AtomicReference<>();

Person person = new Person(1, "张三", new Date());
Person person2 = new Person(2, "李四", new Date());

atomicReference.set(person);

boolean b = atomicReference.compareAndSet(person, person2);

System.out.println(b);
System.out.println(atomicReference.get());

3、CAS 实现自旋锁

public class Test3 {

    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    /**
     * 加锁
     */
    public void lock(){
        Thread thread = Thread.currentThread();
        while (!atomicReference.compareAndSet(null, thread)){

        }
    }

    /**
     * 解锁
     */
    public void unLock(){
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
    }

    @Test
    public void test1() throws InterruptedException {
        Test3 test3 = new Test3();

        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                test3.lock();
                long start = System.currentTimeMillis();
                System.out.println(Thread.currentThread().getName() + "加锁成功");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {
                    System.out.println(Thread.currentThread().getName() + "解锁");
                    test3.unLock();
                    System.out.println(Thread.currentThread().getName() + "加锁时长" + (System.currentTimeMillis() - start));
                }
            }, "t" + i).start();
        }

        TimeUnit.SECONDS.sleep(60);
    }
}

当较少的线程进行争抢锁的时候,加锁时长差不多是一秒(代码中的线程睡眠一秒)

如果 CAS 一直失败,会给 CPU 带来很大的开销,加锁时长会随着争抢资源的线程数增加而增加,我自己的电脑测试如下:

  • 3个线程争抢,加锁时长 1000 ms
  • 10个线程争抢,加锁时长 1017 ms
  • 1000个线程争抢,加锁时长差不多要10秒了

综上,自旋锁不适合大并发的情况。解析见上面第一节的自旋锁的缺点。

4、ABA 问题

CAS 存在的ABA问题

ABA 问题:进行CAS 时,当第一个线程读取共享变量后,其他线程将共享变量修改为其他值,在修改回原来的值,此时第一个线程对这个操作是无感知的,所以进行比较并交换时会成功,所以不能保证线程安全。

其他线程的修改操作可能是以下两种:

  • 第二个线程将共享变量修改为其他值,然后在修改回初始值
  • 第二个线程将共享变量修改为其他值,第三个线程在将共享变量修改为初始值

当读取的初值:X=A,线程1在对X进行操作时,线程2读取了X,并将其修改为B后,在修改为A,此时,线程1对值X计算完毕,比较计算前获取的值X=A与当前的X=A相等,此时无法得知其他线程已经修改过X。

/* ------------------------- ABA 问题 -------------------------------- */

// 创建带版本号的原子类
volatile AtomicStampedReference<Integer> stampedReference = new AtomicStampedReference<>(100, 1);

@Test
public void test2(){

    new Thread(()->{
        // 获取版本号
        int stamp = stampedReference.getStamp();
        System.out.println("线程:" + Thread.currentThread().getName() + ",首次获取版本号:" + stamp);

        // 线程暂停1秒模拟业务执行
        try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { }

        // 首次修改
        // 参数: 期望值,新值,期望版本号,新版本号
        boolean res1 = stampedReference.compareAndSet(100, 101, stampedReference.getStamp(), stampedReference.getStamp() + 1);
        System.out.println("线程:" + Thread.currentThread().getName() + ",2次获取版本号:" + stampedReference.getStamp() + ",值:" + stampedReference.getReference());

        boolean res2 = stampedReference.compareAndSet(101, 100, stampedReference.getStamp(), stampedReference.getStamp() + 1);
        System.out.println("线程:" + Thread.currentThread().getName() + ",3次获取版本号:" + stampedReference.getStamp() + ",值:" + stampedReference.getReference());
    }, "t1").start();

    new Thread(()->{
        // 获取版本号
        int stamp = stampedReference.getStamp();
        System.out.println("线程:" + Thread.currentThread().getName() + ",首次获取版本号:" + stamp);

        // 线程暂停2秒模拟业务执行, 模拟ABA问题
        try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { }

        boolean res = stampedReference.compareAndSet(100, 102, stamp, stamp + 1);
        System.out.println("线程:" + Thread.currentThread().getName() + ",2次获取版本号:" + stampedReference.getStamp() + ",值:" + stampedReference.getReference());
    }, "t2").start();


    try { TimeUnit.SECONDS.sleep(4); } catch (InterruptedException e) { }
}
线程:t1,首次获取版本号:1
线程:t2,首次获取版本号:1
线程:t1,2次获取版本号:2,值:101
线程:t1,3次获取版本号:3,值:100
线程:t2,2次获取版本号:3,值:100

这个地方要注意一下原子类中的比较是通过 == 进行比较的,所以使用 Integer 时,要注意的是大于等于 128 以后2个Integer是不等的

Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 128;
Integer i4 = 128;

System.out.println(i1 == i2);    //true
System.out.println(i3 == i4);    //false

八、原子操作类

原子操作类是指 java.util.concurrent.atomic 包下的类,在 jdk 1.8中,该包下有16个类

1、基本类型-3个

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong

初始化并设置默认值

AtomicBoolean atomicBoolean = new AtomicBoolean(false);

AtomicInteger atomicInteger = new AtomicInteger(1);

AtomicLong atomicLong = new AtomicLong(1L);

通用方法

// 获取当前值
public final int get();

// 设置值
public final void set(int newValue);

// 获取当前值并设置新值
public final int getAndSet(int newValue);

// 获取当前值并自增
public final int getAndIncrement();

// 获取当前值并自减
public final int getAndDecrement();

// 自增,并获取自增后的值
public final int incrementAndGet();

// 自减,并获取自减后的值
public final int decrementAndGet();

// 获取当前值并加上预期值
public final int getAndAdd(int delta);

// 如果输入的值等于预期值,则以原子方式将该值设置为该原子类的值
public final boolean compareAndSet(int expect, int update);

2、数组类型-2个

  • AtomicIntegerArray
  • AtomicLongArray

初始化

// 初始化并设置数组长度
AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(5);

// 初始化并设置初始化数组
new AtomicIntegerArray(new int[]{1, 2, 3});

常用方法(其实大部分方法与上面的基本类型相同,不过是加了数组索引一个参数而已)

// 获取指定下标的值
public final int get(int i);

// 设置指定下标的值
public final void set(int i, int newValue);

// 获取指定下标的值,并为其设置新值
public final int getAndSet(int i, int newValue);

// 如果输入的值等于预期值,则以原子方式将下标为i的值设置为输入的值
public final boolean compareAndSet(int i, int expect, int update);

3、引用类型-3个

  • AtomicReference
  • AtomicStampedReference:带版本号(流水号)的引用原子类型
  • AtomicMarkableReference:代标记的引用原子类型

初始化

Person person = new Person();

// 初始化并设置默认值
AtomicReference<Person> atomicReference = new AtomicReference<>(person);

// 初始化并设置默认值和版本号
AtomicStampedReference<Person> stampedReference = new AtomicStampedReference<>(person, 1);

// 初始化并设置默认值和标记
AtomicMarkableReference<Person> markableReference = new AtomicMarkableReference<>(person, false);

AtomicStampedReferenceAtomicMarkableReference用法区别

AtomicStampedReference 用于记录被修改过几次,解决 CAS 的ABA问题

AtomicMarkableReference 用于记录是否被修改过,可用于初始化值

4、对象属性修改类型-3个

以线程安全的方式操作非线程安全的对象内的某些字段。

  • AtomicIntegerFieldUpdater:原子更新对象中 Integer 类型的字段
  • AtomicLongFieldUpdater:原子更新对象中 Long 类型的字段
  • AtomicReferenceFieldUpdater:原子更新对象中引用类型的字段

使用要求:

  • 更新的对象属性必须使用 public volatile 修饰
  • 因为对象属性修改类型原子类都是抽象类,所以每次使用必须使用静态方法 newUpdater() 创建一个更新器,并且需要设置想要更新的类和字段

使用 synchronized 保证字段原子性

public class Test4 {

    String name = "张三";
    private Integer money = 1;

    public synchronized void add(){
        money++;
    }
}

上面的的写法 synchronized 会对整个对象加锁,但其实我们只需要对 money 字段加锁即可

使用对象属性修改类型保证字段原子性

public class Test4 {

    String name = "张三";

    // 更新的对象属性必须使用public volatile 修饰
    public volatile int money = 0;

    // 创建AtomicIntegerFieldUpdater并指定类和字段
    AtomicIntegerFieldUpdater<Test4> integerFieldUpdater =
            AtomicIntegerFieldUpdater.newUpdater(Test4.class, "money");

    public void add2(){
        // 获取指定对象的指定字段并以原子方式自增
        integerFieldUpdater.getAndIncrement(this);
    }

}

测试代码

    public static void main(String[] args) throws InterruptedException {
        Test4 test4 = new Test4();
        for (int i = 0; i < 10; i++) {
            for (int j = 0; j < 1000; j++) {
                new Thread(()->{
                    test4.add2();
                }).start();
            }
        }

        TimeUnit.SECONDS.sleep(1);
        System.out.println(test4.money);
    }

引用类型的对象修改类型:AtomicReferenceFieldUpdater

案例:保证对象只被初始化一次

public class Test5 {
    public volatile Boolean isInit = Boolean.FALSE;

    AtomicReferenceFieldUpdater<Test5, Boolean> referenceFieldUpdater =
            AtomicReferenceFieldUpdater.newUpdater(Test5.class, Boolean.class, "isInit");

    public void init(){
        if (referenceFieldUpdater.compareAndSet(this, Boolean.FALSE, Boolean.TRUE)){
            System.out.println("初始化----");
            try{ TimeUnit.SECONDS.sleep(1); }catch (Exception e){ }
            System.out.println("初始化完成----");
        }else{
            System.out.println("对象以初始化----");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Test5 test5 = new Test5();
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                test5.init();
            }).start();
        }

        TimeUnit.SECONDS.sleep(3);
    }
}

5、原子操作增强类

在并发量比较低的情况下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发情况下,N个线程同时进行自旋操作,N-1个线程失败,导致CPU打满场景,此时AtomicLong的自旋会成为瓶颈。这就是LongAdder引入的初衷——解决高并发环境下AtomictLong的自旋瓶颈问题。

对于原子操作类基本类型的增强,不保证实时准确性,属于最终一致性。但是在高并发的情况下该类性能优于原子基本类型。可用于统计点赞数等数据实时性要求不高的场景。

  • LongAdder:只能用于从零开始的加法

  • LongAccumulator:提供了自定义函数操作

  • DoubleAdder

  • DoubleAccumulator

下面是阿里巴巴开发手册中的描述:

【参考】volatile 解决多线程内存不可见问题。对于一写多读,是可以解决变量同步问题, 但是如果多写,同样无法解决线程安全问题。如果是 count++操作,使用如下类实现: AtomicInteger count = new AtomicInteger(); count.addAndGet(1); 如果是 JDK8,推 荐使用 LongAdder 对象,比 AtomicLong 性能更好(减少乐观锁的重试次数)。

(1)LongAdder

// 将当前的 value 加 x
public void add(long x);

// 将当前的 value 加 1
public void increment();

// 将当前的 value 减 1
public void decrement();

// 返回当前值,特别注意,在没有并发的情况下,返回精确值;在并发的情况下不保证返回精确值
public long sum();

// 将 value 重置为 0,可用于替代重新 new 一个LongAddr对象,但此方法只能在没有并发的情况下使用
public void reset();

// 获取当前 value 并将 value 重置为 0
public long sumThenReset();

案例

@Test
public void test1(){
    LongAdder longAdder = new LongAdder();

    longAdder.add(10L);
    longAdder.increment();
    long sum = longAdder.sumThenReset();

    System.out.println(sum);
}

(2)LongAccumulator

构造方法

// 参数1:函数式接口,执行的具体操作; x:当前值,y:输入值
// 参数2:初始值
LongAccumulator longAccumulator = new LongAccumulator((x, y) -> x + y, 1);

常用方法

// 执行函数计算,输入值为x
public void accumulate(long x);

// 返回当前值,特别注意,在没有并发的情况下,返回精确值;在并发的情况下不保证返回精确值
public long get();

// 将 value 重置为初始值,可用于替代重新创建对象,但此方法只能在没有并发的情况下使用
public void reset();

// 获取当前 value 并将 value 重置为初始值
public long getThenReset();

使用

LongAccumulator longAccumulator = new LongAccumulator((x, y) -> x + y, 1);

longAccumulator.accumulate(3);  // 1+3=4

longAccumulator.accumulate(5);  // 4+5=9

long sum = longAccumulator.get();   //9

longAccumulator.reset();    //1

6、LongAdder原理解析

image-20220811203943109

(1)Striped64

Striped64中的成员变量

/** 
* Number of CPUS, to place bound on table size 
* 当前计算机CPU数量,Cell数组扩容时会使用到
*/
static final int NCPU = Runtime.getRuntime().availableProcessors();

/**
* Table of cells. When non-null, size is a power of 2.
* cell 数组,为2的次幂
*/
transient volatile Cell[] cells;

/**
* Base value, used mainly when there is no contention, but also as
* a fallback during table initialization races. Updated via CAS.
* 类似于AtomicLong中全局的value值。再没有竞争情况下数据直接累加到base上,
* 或者cells扩容时,也需要将数据写入到base上
*/
transient volatile long base;

/**
* Spinlock (locked via CAS) used when resizing and/or creating Cells.
* 初始化cells或者扩容cells需要获取锁,0表示无锁状态,1表示其他线程已经持有了锁
*/
transient volatile int cellsBusy;

Striped64中的一些变量方法定义

九、对象内存分布

在 HotSpot 虚拟机中,new 出来的对象存放在堆中,对象在内存中的存储布局可以划分为三部分:

  • 对象头(Header):包含对象标记(Mark Word)和类元信息(又叫类型指针)
  • 实例数据(Instance Data
  • 对齐填充(Padding):保证对象占用空间为 8 字节的倍数
image-20220817203119966

1、对象头

对象头中包含:

  • 对象标记Mark Word),在 openjdk 中名称为 markOop
  • 类元信息Class Pointer ,又叫类型指针),在 openjdk 中名称为 klassOop,类元信息存储的是指向该对象类元数据(klass)的首地址

在 64 位操作系统中,对象标记和类元信息各占8个字节,总共16个字节

所以创建一个空的 Object 对象,占用是 16 字节

Object o = new Object();

(1)对象标记

以下均存储在对象标记中:

  • 哈希码
  • GC标记
  • GC次数(分代年龄)
  • 同步锁标记
  • 偏向锁持有者

这些信息都与对象的定义无关,所以 Mark Word 被设计成一个非固定的数据结构以便在极小的空间存储尽可能多的数据。他会根据对象的状态复用自己的空间,也就是说在运行期间,Mark Word 里存储的数据会随锁标志位的变化而变化

(2)类型指针

对象指向它的类元数据(可理解为对象模板,类信息,class)的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2、实例数据

存放类的属性(Field)数据信息,包括父类的属性信息。

3、对齐填充

虚拟机要求对象的起始地址必须是8字节的整数倍。

填充数据并不是必须存在的,仅仅是为了字节对齐。

这部分内存按8字节补充对齐。

十、Synchronized锁升级

1、简介

Synchronized 锁能实现数据的并发安全性,但是其是重量级锁,会导致性能下降;

不加锁性能虽好,但又不能保证数据的并发安全;

所以必须在性能和数据安全之间取得一个平衡点。

以下是锁升级过程:

无锁 –> 偏向锁 –> 轻量级锁 –> 重量级锁

Synchronized 锁:由对象头中 Mark Word(对象标记) 根据锁标志位的不同而被复用及锁升级策略

image-20220820211611275

  • 无锁:初始状态,一个对象被实例化后,如果还没有被任何线程竞争锁,就是无锁状态(001)
  • 偏向锁:MarkWord存储的是偏向线程ID;
  • 轻量锁:MarkWord存储的是指向线程堆栈中 Lock Record的指针;
  • 重量锁:MarkWord存储的是指向堆中的monitor对象的指针;

JVM 中,synchronized 锁只能按照偏向锁、轻量级锁、重量级锁的顺序逐渐升级(也有把这个称为锁膨胀的过程),不允许降级。

轻量级锁和偏向锁引入背景

java 的线程时映射在原生操作系统的线程之上的,如果要阻塞或唤醒线程就需要操作系统的介入,就需要在用户态和内核态之间切换,这种切换回消耗大量的系统资源。

在 java 早期版本,synchronized 属于重量级锁,效率底下,因为监视器锁(monitor)是依赖底层的操作系统的 Mutex Lock (系统互斥量)来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个 java 线程需要操作系统切换Cpu状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中的逻辑过于简单,这种切换消耗的时间可能会比业务执行时间要长,时间成本较高,这也是早期 synchronized 效率底下的原因,Java 6 之后,为了减少获得锁和释放锁带来的性能消耗,引入了轻量级锁和偏向锁。

在Java早期版本中, synchronized属于重量级锁,效率低下,因为监视器锁( monitor)是依赖于底层的操作系统的 Mutex Lock来实现的,Java的线程是映射到操作系统的原生线程之上的。

如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized效率低的原因。

java1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
  • 偏向锁是用来解决只有单个线程反复获取锁的情况,无需重新加锁,避免了用户态和内核态的切换,一旦发生线程争抢锁,便会升级为轻量级锁。
  • 轻量级锁是用来解决线程之间竞争不激烈的情况,避免阻塞线程影响性能,一旦发生激烈争抢,即自旋超过一定次数,便会升级为重量级锁。

Monitor 与 java对象以及线程之间如何关联

Monitor 是 JVM 底层 c++ 实现,本质是依赖于操作系统的 Mutex Lock。

  • 如果一个java对象被某个线程锁住,则该 java 对象的 Mark Word 指向 monitor 的起始地址
  • Monitor 的 Owner 字段会存放拥有相关联对象锁的线程id

img

2、偏向锁

(1)介绍

在大多数多线程的情况下,锁不仅不存在多线程的竞争,还存在锁由同一个线程多次获得的情况。

偏向锁就是在这种情况下出现的,他的出现为了解决在只有一个线程执行同步是提高性能。

当一段同步代码一直被同一个线程多次访问,那么该线程在后续访问时,便会自动获取锁。避免了线程间的切换。

偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他线程访问,则持有偏向锁的线程将永远不需要触发同步。也即偏向锁在资源没有竞争的情况下消除了同步语句,连 CAS 操作都没有,提高了性能。

注意,偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。 那么只需要在锁第一次被拥有的时候(即一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会):

  • 在其所在的Mark Word中将偏向锁修改状态位为1,锁标志位为01
  • 同时还会有占用前54位,来存储当前线程id/线程指针JavaThread*,记录下偏向线程ID。

这样后续这个偏向线程就一直持有着锁,由于之前没有释放锁,这里也就不需要重新加锁,如此自始至终使用锁的线程只有一个,不涉及用户态与内核态的切换以及设置Mutex争取内核。而是直接会去检查锁对象当中的MarkWord里面是不是放的自己的线程ID,无需再进入 Monitor 去竞争对象了,性能极高。

  • 如果相等,表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程 ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明 显偏向锁几乎没有额外开销,性能极高。
  • 如果不等,表示发生了竞争,锁已经不是总是偏向于同一个线程了,这个时候会尝试使用CAS来替换MarkWord里面的线程ID为新线程的ID
  • 竞争成功,表示之前的线程不存在了, MarkWord里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁
  • 竞争失败,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。

技术实现

一个 synchronized 方法被一个线程抢到锁时,那这个方法所在的对象就会在其所在的 Mark Word 中将偏向锁修改状态位,同时还会占用前 54 位来存储线程指针作为标识。若该线程再次访问同一个 synchronized 方法时,该线程只需要去对象头中的 Mark Word 中判断是否有偏向锁指向本身的 ID ,无需再进入 Monitor 竞争对象。

注:java 15 之后废弃偏向锁

(2)代码演示

/**
 * 资源类,模拟3个售票员卖完50张票
 */
class Ticket {
    private int number = 50;

    Object lockObject = new Object();

    public void sale(){
        synchronized (lockObject) {
            if(number > 0){
                System.out.println(Thread.currentThread().getName()+"卖出第:\t"+(number--)+"\t 还剩下:"+number);
            }
        }
    }
}

public class SaleTicketDemo{
    public static void main(String[] args){
        Ticket ticket = new Ticket();

        new Thread(() -> { 
            for (int i = 0; i <55; i++)  
                ticket.sale(); 
        },"a").start();

        new Thread(() -> { 
            for (int i = 0; i <55; i++)  
                ticket.sale(); 
        },"b").start();

        new Thread(() -> { 
            for (int i = 0; i <55; i++)  
                ticket.sale(); 
        },"c").start();
    }
}

输出:几乎都是b线程在做功,这就是偏向锁

(3)JVM 命令

查看偏向锁相关的信息

在 linux 下可通过以下命令查看偏向锁相关的信息

java -XX:+PrintFlagsInitial |grep BiasedLock*

输出如下

[root@VM-20-13-centos ~]# java -XX:+PrintFlagsInitial |grep BiasedLock*
intx BiasedLockingBulkRebiasThreshold    = 20           {product}
intx BiasedLockingBulkRevokeThreshold    = 40           {product}
intx BiasedLockingDecayTime              = 25000        {product}
intx BiasedLockingStartupDelay           = 4000         {product}
bool TraceBiasedLocking                  = false        {product}
bool UseBiasedLocking                    = true         {product}
  • UseBiasedLocking :是否开启偏向锁
  • BiasedLockingStartupDelay :开启偏向锁的延迟时间(毫秒),即开启偏向锁之后需要等待默认4秒才会启用偏向锁

开启、关闭偏向锁

实际上偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,需要程序启动后4秒才开启

所以需要添加参数 -XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。

  • 开启偏向锁:
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

关闭偏向锁:关闭之后程序默认会直接进入 >>>>>> 轻量级锁状态

-XX:-UseBiasedLocking

(4)获取和撤销逻辑

偏向锁的获取逻辑

首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID 写入到 MarkWord

  • a) 如果 cas 成功,那么 markword 就会变成这样。 表示已经获得了锁对象的偏向锁,接着执行同步代码块

  • b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行

如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID

  • a) 如果相等,不需要再次获得锁,可直接执行同步代码块

  • b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

偏向锁的撤销

偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来的线程才会被撤销。

撤销需要等待全局安全点(该事件点上没有字节码正在执行),同时检查持有偏向锁的线程是否还在执行。

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:

  • 1、原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无 锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程

  • 2、如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块

在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁

image-20220830211727806

3、轻量级锁

(1)介绍

轻量级锁是为了在线程近乎交替执行同步块时提高性能。

主要目的:在没有多线程竞争的前提下,通过 CAS 减少重量级锁使用 操作系统互斥量产生的性能消耗,说白了就是先自旋,不行了才升级阻塞。

升级时机:当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁。

轻量级锁只会进行一次CAS,如果失败就调用inflate方法膨胀为重量级锁。

(2)获取过程

假如线程 A 已经拿到锁,这是线程B又来抢该对象的锁,由于该锁是偏向锁,而 B 在争抢时发现对象头 Mark Word 中的线程 ID 不是线程 B 的 ID (而是A 的),那么线程 B 就会进行 CAS 操作希望获得锁。

此时 线程 B 操作中有两种情况:

  • 如果锁获取成功,直接替换Mark Word 中的线程 ID 为 B 自己的 ID,此时,该锁会保持偏向锁状态,A线程Over,B线程上位
  • 如果锁获取失败,则偏向锁升级为轻量级锁(设置偏向锁表示为0,并设置锁标志为为 00),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁

(3)加锁原理

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,官方称为 Displaced Mark Word 。若一个线程获得锁时发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Dispaced Mark Word 里面。然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换为其他线程的锁记录,说明在与其他线程竞争锁,当前线程就尝试使用自旋来获取锁。

(4)锁的释放

在释放锁时,当前线程会使用 CAS 操作将 Displaced Mark Word 的内容复制回锁的 Mark Word 里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成重量级锁,那么 CAS 操作会失败,此时会释放锁并唤醒被阻塞的线程。

(5)最多自旋几次

当轻量级锁自旋达到一定次数时,会升级为重量级锁,那么具体次数是多少呢?

在 JAVA 6 之前

默认情况下自旋次数是10次,或者自旋线程数超过 cpu 核数的一半。

自旋次数可通过以下参数配置:

-XX:PreBlockSpin=10

在 JAVA 6 之后

自适应自旋锁:

  • 线程如果自旋成功了,那下次自旋的最大次数会增加,因为 JVM 认为既然上次成功了,那么这次成功的可能性也大
  • 线程如果自旋失败了,那么下次会减少自旋次数,甚至不自旋,避免 CPU空转

4、重量级锁

(1)介绍

当线程之间发生激烈争抢锁时,就会由轻量级锁升级为重量级锁。

重量级锁的原理是基于 Monitor 对象实现的。在编译时,会在同步块开始的位置插入 monitor enter 指令,在结束位置插入 monitor exit 指令。

当线程执行到 monitor enter 指令时,会尝试去获取对象所对应的 Monitor 的所有权,如果获取到了,即获取到锁,会在 Monitor 的 owner 中存放当前线程的 id,这样他将处于锁定状态,除非退出同步块,否则其他线程无法获取到这个 Monitor

5、锁升级总结

image-20220902133934285

6、锁升级后hashCode去哪了

在无锁状态下Mark Word 中可以存储哈希码,当对象 hashCode() 第一次被调用时,JVM 或生成哈希码并存储到 Mark Word 中。

对于偏向锁,当一个对象已经计算过哈希码后,它就无法进入偏向锁状态了,而当对象正处于偏向锁状态,又需要计算哈希码时,它的偏向锁会立即被撤销,并且锁被膨胀为重量级锁。

锁升级为轻量级锁或重量级锁后,Mark Word 中保存的分别是线程栈帧里的锁记录和重量级锁指针,已经没有位置存储哈希码、GC 年龄。那么他们存在哪呢?

在轻量级锁中,JVM 会在当前线程的栈帧中创建一条锁记录(Lock Record)空间,用于存储对象 Mark Word 拷贝,该拷贝可以包含哈希码,所以轻量级锁可以和哈希码共存,释放锁后这些信息写回对象头。

在重量级锁中,对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor 类里有字段可以记录非加锁状态(标志位为 01)下的 Mark Word,其中自然可以存储哈希码。

7、锁消除

public class LockClearUPDemo{
    static Object objectLock = new Object();//正常的

    public void m1(){
        //锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
        // 因为每个线程持有的锁对象 o 都为自己new的,不是同一个,所以此处 synchronized 相当于没加
        Object o = new Object();

        synchronized (o){
            System.out.println(o.hashCode()+"\t"+objectLock.hashCode());
        }
    }

    public static void main(String[] args){
        LockClearUPDemo demo = new LockClearUPDemo();

        for (int i = 1; i <=10; i++) {
            new Thread(() -> {
                demo.m1();
            },String.valueOf(i)).start();
        }
    }
}

锁消除:从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用

8、锁粗化

假如方法中首位相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,加粗加大范围,一次申请使用即可,避免次次都申请和释放锁,提升了性能。

如下 test5 方法在底层最终会被优化成 test6

static final Object o = new Object();

public void test5(){

    synchronized (o){
        System.out.println(1);
    }

    synchronized (o){
        System.out.println(2);
    }

    synchronized (o){
        System.out.println(3);
    }
}

/**
* jit 优化后
*/
public void test6(){
    synchronized (o){
        System.out.println(1);
        System.out.println(2);
        System.out.println(3);
    }
}

  目录