Java并发基础(4):Synchronized原理和锁优化升级过程

目录

写在前面

1、synchronized使用

1.1、synchronized使用方式

 1.2、验证

1.2.1、普通方法和代码块中使用this是同一个监视器(锁),即具体调用该代码的对象

1.2.2、静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定

1.3、Synchronized使用场景

 2、synchronized的特点

3、synchronized实现原理

3.1、Java对象头

3.2、Synchronized在JVM中的实现原理

3.3、synchronized修饰方法

3.4、synchronized修饰代码块

4、锁升级

4.1、偏向锁:

4.1.1、为什么要引入偏向锁?

4.1.2、偏向锁的升级

4.1.3、偏向锁的取消

4.2、轻量级锁

4.2.1、为什么要引入轻量级锁?

4.2.2、轻量级锁什么时候升级为重量级锁?

4.2.3、自旋

4.3、这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)

4.4、锁粗化

4.5、锁消除

5、锁的类型

5.1、乐观锁

5.2、悲观锁


写在前面

        synchronized关键字在需要原子性、可见性和有序性这三种特性的时候都可以作为其中一种解决方案,看起来是“万能”的。的确,大部分并发控制操作都能使用synchronized来完成。在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁,但是随着Java SE1.6对Synchronized进行了各种优化之后,有些情况下它并不那么重了,Java SE1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。

1、synchronized使用

1.1、synchronized使用方式

Java并发基础(4):Synchronized原理和锁优化升级过程_第1张图片

注意:

  • 使用synchronized修饰实例方法或者使用synchronized修饰代码块时制定的为实例对象时,同一个类的不同对象拥有自己的锁,因此不会相互阻塞。
  • 使用synchronized修饰类和对象时,由于类对象和实例对象分别拥有自己的监视器锁,因此不会相互阻塞。
  • 使用synchronized修饰实例对象时,如果一个线程正在访问实例对象的一个synchronized方法时,其它线程不仅不能访问该synchronized方法,该对象的其它synchronized方法也不能访问,因为一个对象只有一个监视器锁对象,但是其它线程可以访问该对象的非synchronized方法。
  • 线程A访问实例对象的实例synchronized方法时,线程B也可以同时访问实例对象的static synchronized方法,因为前者获取的是实例对象的监视器锁,而后者获取的是类对象的监视器锁,两者不存在互斥关系。
public class Synchronized {
    //synchronized关键字可放于方法返回值前任意位置,本示例应当注意到sleep()不会释放对监视器的锁定
    //实例方法
    public synchronized void instanceMethod() {
        for (int i = 0; i < 5; i++) {
            System.out.println("instanceMethod");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    //静态方法
    public synchronized static void staticMethod() {
        for (int i = 0; i < 5; i++) {
            System.out.println("staticMethod");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void thisMethod() {
        //this对象
        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println("thisMethod");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void classMethod() {
        //class对象
        synchronized (Synchronized.class) {
            for (int i = 0; i < 5; i++) {
                System.out.println("classMethod");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public void anyObject() {
        //任意对象
        synchronized ("anything") {
            for (int i = 0; i < 5; i++) {
                System.out.println("anyObject");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

 1.2、验证

1.2.1、普通方法和代码块中使用this是同一个监视器(锁),即具体调用该代码的对象

   public static void main(String[] args) {
        Synchronized syn = new Synchronized();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                @Override
                public void run() {
                    syn.thisMethod();
                }
            }.start();
            new Thread() {
                @Override
                public void run() {
                    syn.instanceMethod();
                }
            }.start();
        }
    }

thisMethod
thisMethod
thisMethod
thisMethod
thisMethod
instanceMethod
instanceMethod
instanceMethod
instanceMethod
instanceMethod
thisMethod
thisMethod

        输出结果总是以5个为最小单位交替出现,证明sychronized(this)和在实例方法上使用synchronized使用的是同一监视器。如果去掉任一方法上的synchronized或者全部去掉,则会出现instanceMethod和thisMethod无规律的交替输出。

1.2.2、静态方法和代码块中使用该类的class对象是同一个监视器,任何该类的对象调用该段代码时都是在争夺同一个监视器的锁定

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Synchronized syn = new Synchronized();
            new Thread() {
                @Override
                public void run() {
                    syn.staticMethod();
                }
            }.start();
            new Thread() {
                @Override
                public void run() {
                    syn.classMethod();
                }
            }.start();

        }
    }

staticMethod
staticMethod
staticMethod
staticMethod
staticMethod
classMethod
classMethod
classMethod
classMethod
classMethod
staticMethod

1.3、Synchronized使用场景

        Synchronized是一个同步关键字,在某些多线程场景下,如果不进行同步会导致数据不安全,而Synchronized关键字就是用于代码同步。

什么情况下会数据不安全呢,要满足两个条件:

一是数据共享(临界资源),二是多线程同时访问并改变该数据。

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;
    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

该段程序的输出为:2000000

但是如果increase的synchronized被删除,那么很可能输出结果就会小于2000000,这是因为多个线程同时访问临界资源i,如果一个线程A对i=88的自增到89没有被B线程读取到,线程B认为i仍然是88,那么线程B对i的自增结果还是89,那么这里就会出现问题。

 2、synchronized的特点

  • 互斥性,被synchronized修饰的方法同时只能由一个线程执行。
  • 可重入性。
  • 非公平锁,在等待获取锁的过程中不可被中断。
  • 当代码段执行结束或出现异常后会自动释放对监视器的锁定。

3、synchronized实现原理

3.1、Java对象头

首先,我们要知道对象在内存中的布局:已知对象是存放在堆内存中的,对象大致可以分为三个部分,分别是对象头、实例变量和填充字节

对象头:主要是由MarkWord和Klass Point(类型指针)组成,其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据。如果对象是数组对象,那么对象头占用3个字宽(Word),如果对象是非数组对象,那么对象头占用2个字宽。(1word = 2 Byte = 16 bit)。
实例变量:存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐。
填充字符:因为虚拟机要求对象字节必须是8字节的整数倍,填充字符就是用于凑齐这个整数倍。


        通过第一部分可以知道,Synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么Synchronized锁对象是存在哪里的呢?答案是存在锁对象的对象头的MarkWord中。那么MarkWord在对象头中到底长什么样,也就是它到底存储了什么呢?

 在32位的虚拟机中:

Java并发基础(4):Synchronized原理和锁优化升级过程_第2张图片

在64位的虚拟机中:

Java并发基础(4):Synchronized原理和锁优化升级过程_第3张图片

         偏向锁和轻量级锁都是在java6以后对锁机制进行优化时引进的,Synchronized关键字对应的是重量级锁,接下来对重量级锁在Hotspot JVM中的实现锁讲解。

3.2、Synchronized在JVM中的实现原理

        重量级锁对应的锁标志位是10,存储了指向重量级监视器锁的指针,在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),其跟同步相关的数据结构如下:

ObjectMonitor() {
    _count        = 0; //用来记录该对象被线程获取锁的次数
    _waiters      = 0;
    _recursions   = 0; //锁的重入次数
    _owner        = NULL; //指向持有ObjectMonitor对象的线程 
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
  }

我们首先看一下线程在获取锁的几个状态的转换:

线程的生命周期存在5个状态,start、running、waiting、blocking和dead。

对于一个synchronized修饰的方法(代码块)来说:

  1. 当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocking状态。
  2. 当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取。
  3. 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入_Owner区。
  4. 如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1。

那么Synchronized修饰的代码块/方法如何获取monitor对象的呢?

3.3、synchronized修饰方法

        如果synchronized在方法上,有一个ACC_SYNCHRONIZED修饰,表示方法加锁了。然后可以在常量池中获取到锁对象,实际实现原理和同步块一致
在这里插入图片描述

3.4、synchronized修饰代码块

public class TestSynMethod1 {
    synchronized void hello() {

    }

    public static void main(String[] args) {
        String anything = "anything";
        synchronized (anything) {
            System.out.println("hello word");
        }
    }
}

同步块的jvm实现,可以看到它通过monitorentermonitorexit实现锁的获取和释放。通过图片中的注解可以很好的解释synchronized的特性,当代码段执行结束或出现异常后会自动释放对监视器的锁定
在这里插入图片描述

4、锁升级

        Java SE1.6为减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏 向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高 获得锁和释放锁的效率。

4.1、偏向锁:

4.1.1、为什么要引入偏向锁?

        因为经过HotSpot的作者大量研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁

4.1.2、偏向锁的升级

        当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

4.1.3、偏向锁的取消

        偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;

4.2、轻量级锁

4.2.1、为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

4.2.2、轻量级锁什么时候升级为重量级锁?

        线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

        如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。

        但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

4.2.3、自旋

        现在假设有这么一个场景:有两个线程A,B在竞争一个锁,假设A拿到了,这个时候B被挂起阻塞,一直等待A释放了锁B才得到使用权。在操作系统中阻塞和唤醒是一个耗时操作,如果A在很短的时间内就释放了锁,当这个时间与阻塞唤醒比较起来更短的时候,我们将B挂起,其实不是一个最优的选择。 
        自旋是指某线程需要获取锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取锁。虽然CPU的时间被消耗了,但是比线程下文切换时间要少。这个时候使用自旋是划算的。 
        如果是单核处理器,一般建议不要使用自旋锁。因为只有单个处理器,自旋占用的时间片使得代价很高。 
        而偏向锁、轻量锁、重量锁也是一个锁围绕着如何使得程序运行的更加“划算”而进行改变的。

注意:

        为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。

        一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

4.3、这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)

4.4、锁粗化

Java并发基础(4-1):Java锁消除和锁粗化_mingyuli的博客-CSDN博客

        按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 
        但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。 
        锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作

4.5、锁消除

        Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

5、锁的类型

锁从宏观上分类,分为悲观锁与乐观锁。

5.1、乐观锁

        乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

        java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

5.2、悲观锁

        悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。

        Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock


参考:深入理解Java并发之synchronized实现原理_zejian的博客-CSDN博客_synchronized原理

自旋、偏向锁、轻量级锁、重量级锁区别_pocher的博客-CSDN博客

Java并发——Synchronized关键字和锁升级,详细分析偏向锁和轻量级锁的升级_tongdanping的博客-CSDN博客

Java中Synchronized的用法_阳光日志-CSDN博客_synchronized

【Java并发编程之深入理解】Synchronized的使用_青苔小榭-CSDN博客_synchronized

JVM 对锁进行了哪些优化?_vincent_wen0766的博客-CSDN博客

https://segmentfault.com/blog/ressmix_multithread

你可能感兴趣的:(Java并发编程,java,面试)