关于java:你真的了解synchronized和volatile吗

3次阅读

共计 15639 个字符,预计需要花费 40 分钟才能阅读完成。

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

什么是 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。

咱们先会存一下要批改的值,再批改之后再去看一下要批改的值是不是还是咱们存的值如果是统一的则批改,咱们在更新数据罕用的乐观锁就是用的 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

<dependencies>
    <!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.9</version>
    </dependency>
</dependencies>

应用示例

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: 因为下一个对象对齐而造成的损失, 咱们看上面这张图。

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 并不是一开始就加重量级的锁,而是有个缓缓降级的过程。先来看表格

偏差锁 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());
    }
}

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

在字节码层面是以 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 的程序执行。上面加张图不便直观理解这个过程。

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

正文完
 0