你真的了解synchronized和volatile吗?

原文来自于公众号:三不猴子

什么是cas?

cas:compare and swap 比较然后交换,它在没有锁的状态下可以保证多线程的对值得更新。我们可以看一下在jdk中对cas的应用:

/**
 * Atomically increments by one the current value.
 *
 * @return the updated value
 */
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}


public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
}

在Atomic原子类中的自增操作中就使用到了compareAndSwapInt,这里的cas的实现使用的native方法。用一张流程图来理解什么是cas。

image

我们先会存一下要修改的值,再修改之后再去看一下要修改的值是不是还是我们存的值如果是一致的则修改,我们在更新数据常用的乐观锁就是用的cas的机制。

在这里面有个ABA的问题:所谓ABA就是在线程A存了值之后,有个线程B对这个值进行修改,B修改了多次最后结果还是原来那个值,这就是ABA问题,此时需要根据业务场景判断这个值得修改是否需要感知。如果需要感知就可以给这个值再加上一个版本号。

我们用一段代码演示一下cas中ABA的问题吧

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * create by yanghongxing on 2020/5/8 11:03 下午
 */
public class ABA {
    private static AtomicInteger atomicInt = new AtomicInteger(100);

    public static void main(String[] args) throws InterruptedException {
        // 对一个AtomicInteger的值该两次,最后结果与之前相同
        Thread intT1 = new Thread(() -> {
            atomicInt.compareAndSet(100, 101);
            atomicInt.compareAndSet(101, 100);
        });
        
        Thread intT2 = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
            boolean c3 = atomicInt.compareAndSet(100, 101);
            // true,执行成功
            System.out.println(c3);
        });
        intT1.start();
        intT2.start();
    }
}

使用jdk中的AtomicStampedReference可以解决这个问题。最后我们看一下cas实现原理,看一下最后native方法的源码 jdk8u: atomic_linux_x86.inline.hpp

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;

汇编指令 我们看这一条

__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"

asm表示汇编指令,lock表示锁,if如果 mp(%4)表示cpu是多核, cmpxchgl表示 cmp exchange 全称 compare and exchange。
最终实现:

lock cmpxchg 指令

这条汇编指令(硬件指令)表示如果是多核CPU则加上锁。

Java对象在内存的布局

我们先了解一下Java对象在内存中的(详细)布局,这个布局与Java锁的实现息息相关。
使用工具:JOL = Java Object Layout


    
    
        org.openjdk.jol
        jol-core
        0.9
    

使用示例

public class ShowJOL {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

输出

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

OFFSET:从第几个位置开始
size:大小,单位字节,

TYPE DESCRIPTION:类型描述,上面的示例就是object header对象头,

VALUE:值

loss due to the next object alignment: 由于下一个对象对齐而造成的损失,我们看下面这张图。

image

markword:关于锁的信息。

class pointer: 表示对象是属于哪个类的。

instance data:字面理解实例数据,比如在在对象中创建了一个int a 就占4个字节,long b就占8个字节。

padding data:如果上面的数据所占用的空间不能被8整除,padding则占用空间凑齐使之能被8整除。被8整除在读取数据的时候会比较快。

对着这张图我们再看看上面JOL打印出来的数据,第一个和第二个都是markword各 4个字节,第三个是class pointer4个字节,本来还有 instance data 用来存成员变量的但是我们写的没有所以为0,这些总共加起来12个字节不能被8整除,所以我们要对齐加4个字节。(注这里的内存占用是默认开启字节压缩XX:+UseCompressedClassPointers -XX:+UseCompressedOops)

看完了这些东西我们再来执行一下下面的代码

/**
 * create by yanghongxing on 2020/5/11 11:52 下午
 */
public class ShowJOL {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

执行结果:

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

对比这个的输出和第一次我们打印的输出,我们可以得出结论synchronized锁的信息是记录在markword上。
我们做Java开发的经常听到的一句话就是synchronized是个重量级的锁,事实上一定是这样吗?我们可以通过分析markword看看synchronized加锁过程,在早期jdk1.0的时候jdk每次申请的就是重量级的锁,性能比较差,随着后面jdk的升级synchronized的性能有所提升,synchronized并不是一开始就加重量级的锁,而是有个慢慢升级的过程。先来看表格

image

偏向锁Biased Locking:Java6引入的一项多线程优化,偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
自旋锁:自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。一直在自旋也是占用CPU的,如果自旋的线程非常多,自旋次数也非常大CPU可能会跑满,所以需要升级。
重量级锁:内核态的锁,资源开销较大。内部会将等待中的线程进行wait处理,防止消耗CPU。

结合这张表格我们再写一个示例看看synchronized在没有锁竞争的情况下默认是怎么样的。

/**
 * create by yanghongxing on 2020/5/11 11:52 下午
 */
public class ShowJOL {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(Integer.toHexString(o.hashCode()));
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o) {
            System.out.println(Integer.toHexString(o.hashCode()));
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

然后看输出:

5f8ed237

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 37 d2 8e (00000001 00110111 11010010 10001110) (-1898825983)
      4     4        (object header)                           5f 00 00 00 (01011111 00000000 00000000 00000000) (95)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           90 29 7d 06 (10010000 00101001 01111101 00000110) (108865936)
      4     4        (object header)                           00 70 00 00 (00000000 01110000 00000000 00000000) (28672)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

Disconnected from the target VM, address: '127.0.0.1:62501', transport: 'socket'

Process finished with exit code 0

我们在第一行打印了这个Object的hashcode的16进制编码,对比没有加锁的输出这hashcode是存在对象的markword中的。我们再看这个未加锁的markword的二级制值:00000001 00110111 11010010 10001110,看前8位的倒数3位也就001(口语描述不知道是不是准确)对比上面的表格也就是无锁状态,我们再看第二个markword的值000,对应表格就是轻量锁、自旋锁。我们再使用一个存在锁竞争的例子看看是怎么样的。

/**
 * create by yanghongxing on 2020/5/12 7:13 下午
 */
public class MarkwordMain {


    private static Object OBJ = new Object();

    private static void printf() {
        System.out.println(ClassLayout.parseInstance(OBJ).toPrintable());
    }

    private static Runnable RUNNABLE = () -> {
        synchronized (OBJ) {
            printf();
        }
    };

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 3; i++) {
            new Thread(RUNNABLE).start();
        }
        Thread.sleep(Integer.MAX_VALUE);
    }
}

这段代码中我们使用了三个线程去竞争打印这个内存分布的操作,看看输出结果,

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           5a 59 82 ef (01011010 01011001 10000010 11101111) (-276670118)
      4     4        (object header)                           f9 7f 00 00 (11111001 01111111 00000000 00000000) (32761)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           5a 59 82 ef (01011010 01011001 10000010 11101111) (-276670118)
      4     4        (object header)                           f9 7f 00 00 (11111001 01111111 00000000 00000000) (32761)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           5a 59 82 ef (01011010 01011001 10000010 11101111) (-276670118)
      4     4        (object header)                           f9 7f 00 00 (11111001 01111111 00000000 00000000) (32761)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

我们看到是010,对应表格就是重量级锁。

synchronized 锁升级时按照,new - 偏向锁 - 轻量级锁 (无锁, 自旋锁,自适应自旋)- 重量级锁的过程升级的。偏向锁 - markword 上记录当前线程指针,下次同一个线程加锁的时候,不需要争用,只需要判断线程指针是否同一个,所以,偏向锁,偏向加锁的第一个线程 。

有争用 - 锁升级为轻量级锁 - 每个线程有自己的LockRecord在自己的线程栈上,用CAS去争用markword的LockRecord的指针,指针指向哪个线程的LockRecord,哪个线程就拥有锁

自旋超过10次,升级为重量级锁 - 如果太多线程自旋 CPU消耗过大,不如升级为重量级锁,进入等待队列(不消耗CPU)-XX:PreBlockSpin

自旋锁在 JDK1.4.2 中引入,使用 -XX:+UseSpinning 来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。

自适应自旋锁意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

synchronized实现原理

Java源代码级别

synchronized(对象)

字节码层级

使用idea插件jclasslib插件查看字节码,我们以之前代码为例

public class ShowJOL {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}
image
public class ShowJOL {
    public static void main(String[] args) {
        Object o = new Object();
        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}
image

在字节码层面是以monitorenter作为开始锁的开始,以moniterexit作为结束。

汇编级别

我们使用hsdis工具对Java源码进行反编译为汇编代码

/**
 * create by yanghongxing on 2020/5/12 11:45 下午
 */
public class SynchronizedTest {

    private static int c;

    public static synchronized void sync() {
    }

    public static void noSynchronized() {
        int a = 1;
        int b = 2;
        c = a + b;
    }

    public static void main(String[] args) {
        for (int j = 0; j < 1000_000; j++) {
            sync();
            noSynchronized();
        }
    }
}
  0x00000001195d2e4e: lock cmpxchg %r11,(%r10)
  0x00000001195d2e53: je     0x00000001195d2da0
  0x00000001195d2e59: mov    %r13,(%rsp)
  0x00000001195d2e5d: movabs $0x79578d830,%rsi  ;   {oop(a 'java/lang/Class' = 'com/example/demo/SynchronizedTest')}
  0x00000001195d2e67: lea    0x10(%rsp),%rdx
  0x00000001195d2e6c: data32 xchg %ax,%ax
  0x00000001195d2e6f: callq  0x0000000119525860  ; OopMap{off=404}
                                                ;*synchronization entry
                                                ; - com.example.demo.SynchronizedTest::sync@-1 (line 11)

我们看到了开篇提到的lock cmpxchg这条汇编命令,结论是synchronized底层也是使用cas的方式来实现锁。

锁消除 lock eliminate

public void add(String str1,String str2){
         StringBuffer sb = new StringBuffer();
         sb.append(str1).append(str2);
}

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

锁粗化 lock coarsening

public String test(String str){
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

volatile实现应用和原理

首先了解一下volatile的作用:

  1. 禁止指令重拍

  2. 保证内存的可见性

    先看个看个示例

public class VolatileExample {
    // 可见性参数
    /*volatile*/ static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            try {
                // 暂停 0.5s 执行
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("flag 被修改成 true");
        }).start();
        // 一直循环检测 flag=true
        while (true) {
            if (flag) {
                System.out.println("检测到 flag 变为 true");
                break;
            }
        }
    }
}

在不加volatile的时候,在子线程中修改了flag为true,但是父线程中是不可见的,我们加上volatile修饰时”检测到 flag 变为 true“可以输出。再看一个指令重排的例子。

public class VolatileExample1 {
    // 指令重排参数
    private static int a = 0, b = 0;
    private static int x = 0, y = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            Thread t1 = new Thread(() -> {
                // 有可能发生指令重排,先 x=b 再 a=1
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                // 有可能发生指令重排,先 y=a 再 b=1
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println("第 " + i + "次,x=" + x + " | y=" + y);
            if (x == 0 && y == 0) {
                // 发生了指令重排
                break;
            }
            // 初始化变量
            a = 0;
            b = 0;
            x = 0;
            y = 0;
        }
    }
}

程序停止的时候只有先执行, x = b;然后执行 y = a;最后执行 a = 1和b = 1语句时,即发生了指令重排。我们再说一个禁止指令重排的应用。单例模式中保证多线程环境下的单例我们通常会使用双重校验的机制,实现代码如下:

public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;
    private LazyDoubleCheckSingleton() {

    }
    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                if (lazyDoubleCheckSingleton == null) {
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}

对于要保证线程安全的单例最容易想到的方式就是在getInstance方法上加上synchronized就好啦,但是这种方式锁的力度太大,性能不是很好,所以我们在getInstance方法上先判断一下lazyDoubleCheckSingleton这个变量是否为空,如果为空我们就进行加锁。在再进行一次判断如果为空就创建一个对象。这里进行了两次判断所谓通常被称为双重校验。这里的成员变量为什么要加volatile?不加volatile会怎么样?为弄明白这个问题我们先了解一下创建一个对象的过程。

以lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton()为例,

  1. 分配内存给这个对象

  2. 初始化对象

  3. 设置lazyDoubleCheckSingleton 指向刚分配的内存地址

    如果我们不使用volatile修饰这个lazyDoubleCheckSingleton的话可能会出现,1-3-2的执行流程,当执行1-3步之后,此时lazyDoubleCheckSingleton变量已经不为空了,他的值是new出对象的内存地址,此时有个线程过来了 到了if (lazyDoubleCheckSingleton == null) 这一步,判断不为空,就直接return出去了,这个线程拿到的就是一个未初始化的线程。所以我们要使用volatile修饰,保证指令按照1-2-3的顺序执行。下面加张图方便直观了解这个过程。

image

对于在多线程中的执行就变成下面的方式了。
多线程.png

image

版权声明:
作者:Alex
链接:https://www.techfm.club/p/637.html
来源:TechFM
文章版权归作者所有,未经允许请勿转载。

THE END
分享
二维码
< <上一篇
下一篇>>