并发编程(三)synchronized关键字详解

系列文章目录

一:计算机模型&volatile关键字详解
二:java中的锁体系
三:synchronized关键字详解
五:Atomic原子类与Unsafe魔法类详解



一、同步设计器的意义

多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况, 这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状 态的访问。实际上,所有的并发模式在解决线程安全问题时,采用的方案都是 序列化访 问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥 访问。

二、java中同步互斥的实现

在Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock。
同步器的本质就是加锁 加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同 步互斥访问) 不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量 并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享 性,不会导致线程安全问题。

三、synchronized特性

1、原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
volatile是不具备原子性,因为在多线程下,每个线程仍然可以共享资源。比如两个线程同时读取到变量a=1; 线程1对变量+1;线程2对变量+2;若此时线程1结束,将变量刷到主内存,那么线程2,再对变量进行+2操作,此时读取到的变量a等于2,2+2等于4,那么此时变量a的值为4

2、可见性

synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性。
volatile可见性是通过MESI缓存一致性实现,每当值需要修改时都会立即更新主存。其他读取到的数据都会失效,所以volatile是不具备原子性的

3、有序性

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

4、可重入性

synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁

四、synchronized原理详解

1、加锁方式

  • 同步实例方法,锁是当前实例对象
  • 同步类方法,锁是当前类对象
  • 同步代码块,锁是括号里面的对象

2、加锁原理

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实 现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖 底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当 然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等 技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平
在这里插入图片描述
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
在这里插入图片描述
通过 javap -v xxx.class查看字节码文件,如下
在这里插入图片描述
使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor在这里插入图片描述

3、对象的内存布局

那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁 状态的呢?
答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面介绍一下对象的内存布局
在这里插入图片描述
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头 (Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向 锁(线程)ID,偏向时间,数组长度(数组对象)等
  • 实例数据:即创建对象时,对象中成员变量,方法等
  • 对齐填充:对象的大小必须是8字节的整数倍

4、内置锁(ObjectMonitor)

ObjectMonitor底层源码链接
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁

//结构体如下
ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;       //线程的重入次数
  _object       = NULL;  
  _owner        = NULL;    //标识拥有该monitor的线程
  _WaitSet      = NULL;    //等待线程组成的双向循环链表,_WaitSet是第一个节点
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;    //多线程竞争锁进入时的单向链表
  FreeNext      = NULL ;  
  _EntryList    = NULL ;    //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
}  

同步流程
(1)有两个线程,线程A、线程B将竞争锁访问同步代码块,先进入ObjectMonitor的EntrySet中等待锁;
(2)当CPU调度线程A获取到锁则进入同步代码,ObjectMonitor owner属性指向线程A,线程B继续在EntryList中等待;
(3)线程A在同步代码中执行wait,则线程进入WaitSet并释放锁,ObjectMonitor owner属性清空;
(4)CPU调度使线程B获取到锁进入同步代码块,ObjectMonitor owner属性指向线程B,任务执行完退出同步代码之前调用notifyAll,线程A被唤醒,从WaitSet转到EntryList中等待锁,线程B退出同步代码块,ObjectMonitor owner属性清空;
(5)CPU调度使线程A获取同步锁,继续后续代码;

作者:杨0612
链接:https://www.jianshu.com/p/44fc7b1e5d6c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

五、锁的膨胀升级过程

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁,随着锁的 竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单 向的,也就是说只能从低到高升级,不会出现锁的降级

JDK1.6版本之后对synchronized的实现进行了各种优化,如自旋锁、偏向锁和轻量级锁
并默认开启偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking
下图为锁的升级全过程:
在这里插入图片描述

  1. 偏向锁 偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过 研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多 次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引 入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模 式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需 再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从 而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效 果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激 烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相 同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏 向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接 着了解轻量级锁。
  2. 轻量级锁 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种 称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量 级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同 步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应 的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就 会导致轻量级锁膨胀为重量级锁。
  3. 自旋锁轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进 行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都 不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实 现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对 比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程 可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为 自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环 后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作 系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。
  4. 锁消除 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编 译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编 译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种 方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的 append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量, 并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情 景,JVM会自动将其锁消除。

六、逃逸分析

1、什么是逃逸分析

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。
以下例子分析了对象、变量是否发生了逃逸。

public class EscapeTest {

    public static Object globalVariableObject;

    public Object instanceObject;

    public void globalVariableEscape(){
        globalVariableObject = new Object(); //静态变量,外部线程可见,发生逃逸
    }

    public void instanceObjectEscape(){
        instanceObject = new Object(); //赋值给堆中实例字段,外部线程可见,发生逃逸
    }
    
    public Object returnObjectEscape(){
        return new Object();  //返回实例,外部线程可见,发生逃逸
    }

    public void noEscape(){
        synchronized (new Object()){
            //仅创建线程可见,对象无逃逸
        }
        Object noEscape = new Object();  //仅创建线程可见,对象无逃逸
    }

}

使用逃逸分析,编译器可以对代码做如下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可 以不考虑同步。
  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远 不会逃逸,对象可能是栈分配的候选,而不是堆分配
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问 到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中
    在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,
    ­ XX:+DoEscapeAnalysis : 表示开启逃逸分析 ­
    XX:­DoEscapeAnalysis : 表示关闭逃逸分析
    从jdk 1.7开始已经默认开启逃逸分析,如需关闭,需要指定­XX:­ DoEscapeAnalysis

2、为什么要有逃逸分析

Java 中对象的创建一般会由堆内存去分配内存空间来进行存储,在堆内存空间不足的时候,GC 便会对堆内存进行垃圾回收,如果 GC 运行的次数过多,便会影响程序的性能,所以 “逃逸分析” 由此诞生,它的目的就是判断哪些对象是可以存储在栈内存中而不用存储在堆内存中的,从而让其随着线程的消逝而消逝,进而减少了 GC 发生的频率,这也是常见的 JVM 优化技巧之一。

3、是不是所有的对象都会在堆内存分配空间?

当我们知道逃逸分析,对象不一定都会在堆内存分配空间。

七、synchronized补充

1、非公平锁

synchronized是非公平锁,若使用公平锁,请使用ReentrantLock去实现。

public class NonfairSyncTest {

    public static void main(String[] args) throws InterruptedException {
        String lock = "lock";
        CountDownLatch downLatch = new CountDownLatch(100);
        IntStream.range(0,100).forEach(x->{
            CompletableFuture.runAsync(()->{
                synchronized (lock){
                    System.out.println("当前线程执行:"+x);
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    downLatch.countDown();
                }
            });
        });
        System.out.println("主线程等待");
        downLatch.await();
        System.out.println("执行结束");
    }
}

结果如下:
在这里插入图片描述
修改下代码,使用ReentrantLock的公平锁,

 public static void main(String[] args) throws InterruptedException {
        //创建一个公平锁
       ReentrantLock lock = new ReentrantLock(true);
       CountDownLatch downLatch = new CountDownLatch(100);
        IntStream.range(0,100).forEach(x->{
            CompletableFuture.runAsync(()->{
                lock.lock();
                System.out.println("当前线程执行:"+x);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock.unlock();
                downLatch.countDown();
            });
        });
        System.out.println("主线程等待");
        downLatch.await();
        System.out.println("执行结束");
    }

结果如下:
在这里插入图片描述

2、死锁

在使用synchronized同步方法时,请注意产生死锁,如下代码就会产生死锁;

 public static class A{}
    public static class B{}

    public static void main(String[] args) throws InterruptedException {
        A a = new A();
        B b = new B();
        CountDownLatch latch = new CountDownLatch(200);
        IntStream.range(0,100).forEach(xx->{
            CompletableFuture.runAsync(()->{
                    synchronized (a){
                        System.out.println("第一组当前执行:"+xx);
                        synchronized (b){
                            System.out.println("第一组当前执行:"+xx);
                        }
                    }
                latch.countDown();
            });
        });
        IntStream.range(0,100).forEach(xx->{
            CompletableFuture.runAsync(()->{
                synchronized (b){
                    System.out.println("第二组当前执行:"+xx);
                    synchronized (a){
                        System.out.println("第二组当前执行:"+xx);
                    }
                }
                latch.countDown();
            });
        });
        System.out.println("执行结束1");
        latch.await(100, TimeUnit.SECONDS);
        System.out.println("执行结束2");
    }

使用jps查看进程
在这里插入图片描述
使用jdk自带的进程查看命令 jstack pid查看发现程序死锁
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值