Java关键字——synchronized

一、并发编程中的三个问题

1.可见性问题

  可见性(Visibility):是指一个线程对共享变量进行修改,另一个先立即得到修改后的最新值。

案例演示:一个线程根据boolean类型的标记flag, while循环,另一个线程改变这个flag变量的值,另一个线程并不会停止循环。

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.concurrent.TimeUnit;
/**
 * @description:    验证可见性
 * @author: Liu Wen          一个线程对共享变量的修改,另一个线程不能立即得到最新值
 * @create: 2020-03-26 23:13
 **/
public class Test01Visibility {
    public static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            while (flag){
                //所以程序会一直运行,不会终止
            }
        },"Thread-1").start();

        TimeUnit.SECONDS.sleep(2);

        new Thread(()->{
            flag = false;
            System.out.println(Thread.currentThread().getName()+"已经将flag改为:"+flag);
        },"Thread-2").start();
    }
}

在这里插入图片描述明明已经将flag改为false,但是程序却不可停止。分析原因:并发编程时,会出现可见性问题,当一个线程对共享变量进行了修改,另外的线程并没有立即看到修改
后的最新值。

2.原子性问题

  原子性(Atomicity):在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。

案例演示:5个线程各执行1000次 i++;

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.ArrayList;
/**
 * @description:    验证原子性
 * @author: Liu Wen
 * @create: 2020-03-26 23:26
 **/
public class Test02Atomicity {
    private static int number = 0;
    public static void main(String[] args) throws InterruptedException{
        Runnable runnable = ()->{
            for (int i = 0; i < 1000; i++) {
                number++;
            }
        };
        ArrayList<Thread> arrayList = new ArrayList<>();
        //开启五个线程运行runnable任务。
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(runnable);
            t.start();
            arrayList.add(t);
        }
        //为了避免main主线程先执行完毕
        for (Thread t : arrayList) {
            t.join();
        }
        System.out.println("number:"+number);
    }
}

在这里插入图片描述
会出现输出<5000的情况。分析原因:并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半时,另外的线程也有可能来操作共享变量,干扰了前一个线程的操作。

补充:number++不是一个原子性操作,它分为三步:取值,+1,赋值三个操作。

3.有序性问题

  有序性(Ordering):是指程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

案例演示:打印i和j的值。

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
/**
 * @description: Good good study,day day up!
 * @author: Liu Wen
 * @create: 2020-03-27 19:05
 **/
public class Test03Order2 {
    private static int i = 0, j = 0;
    private static int a = 0, b = 0;
//    private volatile static int i = 0, j = 0;   //禁止重排序
//    private volatile static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        int count = 0; // 计数
        while(true) {
            count++;
            i = 0;
            j = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    i = b;
                }
            });
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    j = a;
                }
            });
            two.start();
            one.start();
            one.join();
            two.join();
            String result = "第" + count + "次( i= " + i + ", j= " + j + ")";
            if (i == 0 && j == 0) {
                System.out.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

在这里插入图片描述

代码有四种结果:i=0,j=1;i=1,j=0;i=1,j=1;i=0,j=0。分析原因:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。

二、Java内存模型(JMM)

1.CPU 缓存 内存

CPU:中央处理器,是计算机的控制和运算的核心,我们的程序最终都会变成指令让CPU去执行,处理程序中的数据。

内存:我们的程序都是在内存中运行的,内存会保存程序运行时的数据,供CPU处理。

缓存:CPU的运算速度和内存的访问速度相差比较大。这就导致CPU每次操作内存都要耗费很多等待时间。内存的读写速度成为了计算机运行的瓶颈。于是就有了在CPU和主内存之间增加缓存的设计。最靠近CPU的缓存称为L1,然后依次是 L2,L3和主内存,CPU缓存模型如图下图所示。

在这里插入图片描述

CPU Cache分成了三个级别: L1, L2, L3。级别越小越接近CPU,速度也更快,同时也代表着容量越小。

  1. L1是最接近CPU的,它容量最小,例如32K,速度最快,每个核上都有一个L1 Cache。
  2. L2 Cache 更大一些,例如256K,速度要慢一些,一般情况下每个核上都有一个独立的L2 Cache。
  3. L3 Cache是三级缓存中最大的一级,例如12MB,同时也是缓存中最慢的一级,在同一个CPU插槽之间的核共享一个L3 Cache。

  Cache的出现是为了解决CPU直接访问内存效率低下问题的,程序在运行的过程中,CPU接收到指令后,它会最先向CPU中的一级缓存(L1 Cache)去寻找相关的数据,如果命中缓存,CPU进行计算时就可以直接对CPU Cache中的数据进行读取和写人,当运算结束之后,再将CPUCache中的最新数据刷新到主内存当中,CPU通过直接访问Cache的方式替代直接访问主存的方式极大地提高了CPU 的吞吐能力。但是由于一级缓存(L1 Cache)容量较小,所以不可能每次都命中。这时CPU会继续向下一级的二级缓存(L2 Cache)寻找,同样的道理,当所需要的数据在二级缓存中也没有的话,会继续转向L3Cache、内存(主存)和硬盘。

2.Java内存模型

  Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。

  **Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。**具体如下:
主内存
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
工作内存
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。

在这里插入图片描述

  Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

在这里插入图片描述
注意:

  1. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值。
  2. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中。

主内存与工作内存之间的数据交互过程:

lock -> read -> load -> use -> assign -> store -> write -> unlock

Java内存模型的作用:Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。(提供synchronized,volatile)

3.JMM内存模型与CPU硬件内存架构的关系

  Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存和主内存之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

在这里插入图片描述

三、synchronized如何保证三大特性

基本原理:synchronized能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。

1.synchronized与原子性

代码示例

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
import java.util.ArrayList;
/**
 * @description:    验证原子性
 * @author: Liu Wen
 * @create: 2020-03-26 23:26
 **/
public class Test02Atomicity {
    private static int number = 0;
    private static Object obj = new Object();        //保证原子操作
    public static void main(String[] args) throws InterruptedException{
        Runnable runnable = ()->{
            for (int i = 0; i < 1000; i++) {
                synchronized (obj) {
                    number++;
                }
            }
        };
        ArrayList<Thread> arrayList = new ArrayList<>();
        //开启五个线程运行runnable任务。
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(runnable);
            t.start();
            arrayList.add(t);
        }
        //为了避免main主线程先执行完毕
        for (Thread t : arrayList) {
            t.join();
        }
        System.out.println("number:"+number);
    }
}

在这里插入图片描述

输出5000,符合预期的结果。分析原因:对number++;增加同步代码块后,synchronized保证只有一个线程拿到锁,能够进入同步代码块。保证同一时间只有一个线程操作number++,就不会出现安全问题。

2.synchronized与可见性

代码示例

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;

import java.util.concurrent.TimeUnit;

/**
 * @description:    验证可见性
 * @author: Liu Wen          一个线程对共享变量的修改,另一个线程不能立即得到最新值
 * @create: 2020-03-26 23:13
 **/
public class Test01Visibility {
    public static boolean flag = true;
//    public static volatile boolean flag = true;    //保证可见性
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{

            while (flag){
                // 增加对象共享数据的打印,println是同步方法
                    System.out.println(flag);
            }
        },"Thread-1").start();

        TimeUnit.SECONDS.sleep(2);

        new Thread(()->{
            flag = false;
            System.out.println(Thread.currentThread().getName()+"已经将flag改为:"+flag);
        },"Thread-2").start();
    }
}

在这里插入图片描述

分析原因:println是同步方法,synchronized保证可见性的原理,执行synchronized时,会对应lock原子操作会刷新工作内存中共享变量的值。

在这里插入图片描述

  1. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值。
  2. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中。
3.synchronized与有序性

  为了提高程序的执行效率,编译器和CPU会对程序中代码进行重排序。但必须遵从as-if-serial语义。as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。

  编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

  synchronized可以保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。从而保证有序性。

补充:如果只是为了保证有序性,不建议用synchronized关键字来保证有序性,建议使用volatile关键字,它是一个轻量级操作。如下代码:

package com.liuwen.JVM虚拟机.关键字synchronized.三个特性;
/**
 * @description:   volatile 可以禁止指令重排序
 * @author: Liu Wen
 * @create: 2020-03-27 19:05
 **/
public class Test03Order2 {
    private volatile static int i = 0, j = 0;   //禁止重排序
    private volatile static int a = 0, b = 0;
    public static void main(String[] args) throws InterruptedException {
        int count = 0; // 计数
        while(true) {
            count++;
            i = 0;
            j = 0;
            a = 0;
            b = 0;
            Thread one = new Thread(new Runnable() {
                @Override
                public void run() {
                    a = 1;
                    i = b;
                }
            });
            Thread two = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    j = a;
                }
            });
            two.start();
            one.start();
            one.join();
            two.join();
            String result = "第" + count + "次( i= " + i + ", j= " + j + ")";
            if (i == 0 && j == 0) {
                System.out.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }
    }
}

在这里插入图片描述
代码永远不会找到 i=0,j=0 的情况。因为禁止了重排序,所以一定不会出现重排的情况。

四、synchronized的特性

1.可重入特性

可重入定义:一个线程可以多次执行synchronized,可以重复获取同一把锁。

可重入原理:synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁。

可重入的好处:可以避免死锁;可以让我们更好的来封装代码。

总结:synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁啦,在执行完同步代码块时,计数器的数量会-1,知道计数器的数量为0,就释放这个锁。

2.不可中断特性

不可中断定义:一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。

synchronized属于不可被中断。
Lock的lock方法是不可中断的。
Lock的tryLock方法是可中断的。

补充面试题:synchronized与Lock的区别
  1. synchronized是关键字,而Lock是一个接口。
  2. synchronized会自动释放锁,而Lock必须手动释放锁。
  3. synchronized是不可中断的,Lock可以中断也可以不中断。
  4. 通过Lock可以知道线程有没有拿到锁,而synchronized不能。(如果在指定时间内知道自己拿不到锁,则可中断)
  5. synchronized能锁住方法和代码块,而Lock只能锁住代码块。
  6. Lock可以使用读锁提高多线程读效率。(读写分离)
  7. synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

五、synchronized原理

1.通过反汇编看原理

对下列代码进行反汇编

package com.liuwen.JVM虚拟机.关键字synchronized.syn锁;
/**
 * @description:           通过字节码文件查看syn原理
 * @author: Liu Wen
 * @create: 2020-03-27 08:05
 **/
public class Demo01 {
    private static Object obj = new Object();
    public static void main(String[] args){
        synchronized (obj){
            System.out.println("1");
        }
    }
    public synchronized void test(){
        System.out.println("a");
    }
}

要看synchronized的原理,但是synchronized是一个关键字,看不到源码。我们可以将class文件进行反汇编。

我是通过在IDEA的添加工具方式实现反汇编的:
在这里插入图片描述

 public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field obj:Ljava/lang/Object;
       3: dup
       4: astore_1
       5: monitorenter
       6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #4                  // String 1
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: aload_1
      15: monitorexit
      16: goto          24
      19: astore_2
      20: aload_1
      21: monitorexit
      22: aload_2
      23: athrow
      24: return

5:monitorenter 21:monitorexit 这是由JVM的C++创建的对象。

通过javap反汇编我们看到synchronized使用monitorentor和monitorexit两个指令。每个锁对象都会关联一个monitor(监视器,它才是真正的锁对象),它内部有两个重要的成员变量owner会保存获得锁的线程,recursions会保存线程获得锁的次数,当执行到monitorexit时,recursions会-1,当计数器减到0时这个线程就会释放锁。

可以简单理解为下图:

在这里插入图片描述

对于monitorenter :

  synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待。

对于monitorexit:

  monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。(因此,synchronized出现异常时会释放锁哦)

补充:对于同步方法,同步方法在反汇编后,会增加 ACC_SYNCHRONIZED 修饰。这会隐式调用monitorenter和
monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。

2.通过JVM源码分析synchronized的原理

monitor监视器锁:可以看出无论是synchronized代码块还是synchronized方法,其线程安全的语义实现最终依赖一个叫monitor的东西,那么这个神秘的东西是什么呢?

在HotSpot虚拟机中,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中(src/share/vm/runtime/objectMonitor.hpp)。ObjectMonitor主要数据结构如下:

ObjectMonitor() {
        _header = NULL;
        _count = 0;
        _waiters = 0,
        _recursions = 0;   // 线程的重入次数
        _object = NULL;    // 存储该monitor的对象
        _owner = NULL;     // 标识拥有该monitor的线程
        _WaitSet = NULL;   // 处于wait状态的线程,会被加入到_WaitSet
        _WaitSetLock = 0 ;
        _Responsible = NULL;
        _succ = NULL;
        _cxq = NULL;       // 多线程竞争锁时的单向列表
        FreeNext = NULL;
        _EntryList = NULL; // 处于等待锁block状态的线程,会被加入到该列表
        _SpinFreq = 0;
        _SpinClock = 0;
        OwnerIsThread = 0;
}
  1. _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的。
  2. _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈)。
  3. _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中。
  4. _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。
    在这里插入图片描述

  每一个Java对象都可以与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。
  Java代码里不会显示地去创造这么一个monitor对象,我们也无需创建,可以这么理解:monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对象创建关联的monitor对象。每个线程都存在两个ObjectMonitor对象列表,分别为free和used列表。同时JVM中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从线程自身的free表中申请,若存在则使用,若不存在则从global list中申请。

monitor竞争、monitor获取、monitor等待、monitor释放等操作都可以到JVM源码中找出原理,不得不佩服写这些JVM源码的大佬们。这些源码太难读懂了,有时间再攻读吧。源码下载阅读:http://openjdk.java.net/ --> Mercurial --> jdk8 --> hotspot --> zip

3.为什么说monitor是重量级锁(用户态和内核态)

  ObjectMonitor的函数调用中会涉及到内核函数(Atomic::cmpxchg_ptr,Atomic::inc_ptr等),执行同步代码块,没有竞争到锁的对象会park()被挂起,竞争到锁的线程会unpark()唤醒。这个时候就会存在操作系统用户态和内核态的转换,这种切换会消耗大量的系统资源。所以synchronized是Java语言中是一个重量级(Heavyweight)的操作。

要了解用户态内核态,先了解一下Linux系统的体系架构:

在这里插入图片描述

从上图可以看出,Linux操作系统的体系架构分为:用户空间(应用程序的活动空间)和内核。
内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存
储资源、I/O资源等。
系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。

  所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态);但是当它调用系统调用执行某些操作时,例如 I/O调用,此时需要陷入内核中运行,我们就称进程处于内核运行态(或简称为内核态)。 系统调用的过程可以简单理解为:

  1. 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈, 以此表明需要操作系统提供的服务。
  2. 用户态程序执行系统调用。
  3. CPU切换到内核态,并跳到位于内存指定位置的指令。
  4. 系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务。
  5. 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。

由此可见用户态切换至内核态需要传递许多变量,同时内核还需要保护好用户态在切换时的一些寄存器值、变量等,以备内核态切换回用户态。这种切换就带来了大量的系统资源消耗,这就是在synchronized未优化之前,效率低的原因。

所以在JDK6中,对synchronized做了优化处理。

六、JDK6 synchronized优化

1.CAS

CAS:Compare And Swap(比较相同再交换)。CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证。CAS可以保证共享变量赋值时的原子操作。CAS操作依赖3个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存到内存中

JUC中atomic包下的类都是原子类,原理就是使用CAS操作。代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.syn锁优化;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
/**
 * @description: Good good study,day day up!
 * @author: Liu Wen
 * @create: 2020-03-27 09:11
 **/
public class Demo01CAS {
    private static AtomicInteger atomicInteger = new AtomicInteger();
    public static void main(String[] args) throws InterruptedException{
        Runnable runnable = ()->{
            for (int i = 0; i < 1000; i++) {
               atomicInteger.incrementAndGet();
            }
        };
        ArrayList<Thread> arrayList = new ArrayList<>();
        //开启五个线程运行runnable任务。
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(runnable);
            t.start();
            arrayList.add(t);
        }
        //为了避免main主线程先执行完毕
        for (Thread t : arrayList) {
            t.join();
        }
        System.out.println("number:"+atomicInteger);
    }
}
/*
5000
*/

上段代码在没有做任何同步的情况下,代码输出正确,满足原子性操作。为什么???????

查看 AtomicInteger 源码如下:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    private static final Unsafe unsafe = Unsafe.getUnsafe(); //提供原子性
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    private volatile int value;      //提供可见性
    //.............
}

分析:AtomicInteger的源码中Unsafe类提供了原子性,基于CAS原理。以incrementAndGet()为例:

CAS操作流程:假设两个线程t1和t2来执行incrementAndGet()方法,其中内存值是由var1和var2寻址得到的最新的(由volatile保证)内存值:value,旧的预期值var5,新值var5+var4。极端现象:t1和t2都执行到doWhile的循环体,都得到旧的预估值var5=0,这时t1继续执行,t2先执行其它。t1对while循环条件进行判断,cas操作返回true,while判断条件为false,循环结束,新值赋给var5返回,所以得到最新的内存值value为1;这时候t2回来执行while判断,cas返回false,所以判断条件为true,则继续执行do循环体,通过var1和var2寻址得到旧的预估值var5=1,再进行while判断,完成赋值,得到var5=2,最后将var5返回给value。

在这里插入图片描述

补充:从通过var1、var2的寻址得到value的操作可以看出,Unsafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针的问题。过度的使用Unsafe类会使得出错的几率变大,因此Java官方并不建议使用的,官方文档也几乎没有。Unsafe对象不能直接调用,只能通过反射获得。

CAS操作是一种乐观锁的实现方式:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,就算改了也没关系,再重试即可。所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去修改这个数据,如何没有人修改则更新,如果有人修改则重试。

CAS总结:CAS获取共享变量时,为了保证该变量的可见性,需要使用volatile修饰。结合CAS和volatile可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
  1.因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
  2.但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。

2.锁升级

  高效并发是从JDK 5到JDK 6的一个重要改进,HotSpot虛拟机开发团队在这个版本上花费了大量的精力去实现各种锁优化技术,包括偏向锁( Biased Locking )、轻量级锁( Lightweight Locking )和如适应性自旋(Adaptive Spinning)、锁消除( Lock Elimination)、锁粗化( Lock Coarsening )等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

锁升级:无锁–》偏向锁–》轻量级锁–》重量级锁

如何升级?必须了解java对象。Java对象由3部分组成,对象头,实例数据,对齐数据。

在这里插入图片描述

  对象头由两部分组成,一部分用于存储自身的运行时数据,称之为 Mark Word,它记录了对象和锁有关的信息,另外一部分是类型指针,及对象指向它的类元数据的指针,JVM通过这个指针确定对象是哪个类的实例。(为了节约内存可以使用选项-XX:+UseCompressedOops 开启指针压缩)

  Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:

在这里插入图片描述

3.偏向锁

  偏向锁是JDK 6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。

  偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及ThreadID即可。

偏向锁原理:
当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:
1.虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
2.同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。

偏向锁的撤销
1.偏向锁的撤销动作必须等待全局安全点
2.暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
3.撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态

偏向锁好处:偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问比如线程池,那偏向模式就是多余的。

补充:在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。但在应用程序启动几秒钟之后才激活,可以使用-XX:BiasedLockingStartupDelay=0 参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过XX:-UseBiasedLocking=false 参数关闭偏向锁。

4.轻量级锁

  在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

  对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

轻量级锁原理
  当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其获取锁步骤如下:
  1.判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
  2.JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到锁,则将锁标志位变成00,执行同步操作。
  3.如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。

轻量级锁的释放
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
1.取出在获取轻量级锁保存在Displaced Mark Word中的数据。
2.用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。
3.如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。

轻量级锁好处:在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。

5.自旋锁与自适应自旋锁

  monitor实现锁的时候,知道monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁

  自旋锁在JDK 1.4.2中就已经引入 ,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。自旋次数的默认值是10次,用户可以使用参数-XX : PreBlockSpin来更改。

  在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明”了。

6.锁消除

  锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

package com.liuwen.JVM虚拟机.关键字synchronized.syn锁优化;
/**
 * @description:    锁消除
 * @author: Liu Wen
 * @create: 2020-03-31 16:45
 **/
public class Demo02LockElimination {
    public static void main(String[] args) {
        contactString("aa", "bb", "cc");
    }
    public static String contactString(String s1, String s2, String s3) {
        return new StringBuffer().append(s1).append(s2).append(s3).toString();
    }
}

append()方法是同步操作,源码如下:

@Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

  StringBuffer的append ( ) 是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

7.锁粗化

锁粗化:JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。

代码示例:

package com.liuwen.JVM虚拟机.关键字synchronized.syn锁优化;
/**
 * @description:   锁粗化
 * @author: Liu Wen
 * @create: 2020-03-31 16:50
 **/
public class Demo03LockCuhua {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < 100; i++) {
            sb.append("aa");
        }
        System.out.println(sb.toString());
    }
}

  补充:原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

8.synchronized使用建议

a.减少synchronized的范围

同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。

synchronized (Demo01.class) {
		System.out.println("aaa");
}

b.降低synchronized锁的粒度

Hashtable:锁定整个hash表,一个操作正在进行时,其它操作阻塞等待,效率低下。

ConcurrentHashMap:局部锁定(锁分段机制),各个分段之间的操作互不影响,效率高于Hashtable。

c.读写分离

LinkedBlockingQueue在出队(读)和入队(写)的时候使用两把锁,因此take()(读)和put()(写)互不影响。

读取时不加锁,写入或删除时加锁,如:ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

进击的程序猿~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值