并发编程2_synchronized锁

Posted 臭小子帅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程2_synchronized锁相关的知识,希望对你有一定的参考价值。

一、安全性问题思考

线程的合理使用能够提升程序的处理性能,主要有两个方面,

第一个是能够利用多核 cpu 以及超线程技术来实现线程的并行执行;

第二个是线程的异步化执行相比于同步执行来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量

同时,也带来了很多麻烦,举个简单的例子

1、多线程对于共享变量访问带来的安全性问题

一个变量 i, 假如一个线程去访问这个变量进行修改,这个时候对于数据的修改和访问没有任何问题。但是如果多个线程对于这同一个变量进行修改,就会存在一个数据安全性问题

举个demo

搞1000个线程去调用i++;如果线程安全,正常结果应该是1000

public class SynchronizedDemo{

    private static int i = 0;

    // 1s +1
    public static void add() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int j = 0; j < 1000; j++) {
            new Thread(()->SynchronizedDemo.add()).start();
        }
        //sleep 3s,让所有线程能执行完
        Thread.sleep(3000);
        System.out.println("输出结果:"+i);
    }
}

实际执行结果 i是<=1000的。可见是存在线程安全问题的;

对于线程安全性,本质上是管理对于数据状态的访问,而且这个这个状态通常是共享的、可变的。共享,是指这个数据变量可以被多个线程访问;可变,指这个变量的值在它的生命周期内是可以改变的。

一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及程序中是如何去使用这个对象的。所以,如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。

2、思考如何保证线程并行的数据安全性

我们可以思考一下,问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使得线程的并行变成串行,那是不是就不存在这个问题呢?

按照大家已有的知识,最先想到的应该就是锁吧。

什么是锁?它是处理并发的一种同步手段,而如果需要达到前面我们说的一个目的,那么这个锁一定需要实现互斥的特性。

Java 提供的加锁方法就是 Synchroinzed 关键字。

例如上面的线程安全问题,解决如下

    public static void add() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //锁住
        synchronized (SynchronizedDemo.class){
            i++;
        }
    }

二、synchronized的基本认识

在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对
synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

synchronized的基本语法

Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

  • 普通方法,锁是当前实例对象。进入同步代码之前要获得当前实例的锁

  • 静态同步方法,锁是当前类的class对象,进入同步代码之前要获得当前类对象的锁

  • 同步方法块,锁是Synchronized括号里配置的对象。进入同步代码块之前要获得给定对象的锁。

    不同的修饰类型,代表锁的控制粒度。

public class SynchronizedDemo{
    //(1) 锁静态方法--锁的是Class对象
    public synchronized static void add() {
        
    }
    //(2) 静态代码块--锁的是Class对象
    public synchronized static void add2() {
        //TODO
        synchronized (SynchronizedDemo.class){
            
        }
        //TODO
    }
    
    //(3)锁普通方法--锁的是实例对象
    public synchronized void hh(){
        
    }

    //(4)静态代码块--锁的是实例对象
    public void hh2(){
        //TODO
        synchronized (this){
            
        }
        //TODO
    }
}

(1)(2)锁的是同一个Class对象,(3)(4)锁的是同一个实例对象;只是锁的粒度不同;
(1)(3)锁的是整个方法,而(2)(4)锁的代码块,更加灵活,锁的粒度相对低一些。

synchronized的实现原理

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。同步代码块使用monitorenter和monitorexit指令实现的。

monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法结束处和异常处,JVM要保证两个指令一一对应。

任何一个对象都有一个monitor监视器与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

image-20210706220515234

当一个线程试图访问同步代码块时,它首先必须得获得锁,退出或抛出异常时必须释放锁。

锁是如何存储的

synchornized用的锁是存在java对象头里的。具体可以看下《java并发编程的艺术》

image-20210706223507660

三、锁的升级

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了 偏向锁 和 轻量级锁。 锁一种4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态随着竞争情况逐渐升级。只能升级,不能降级。

偏向锁的基本原理

大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。怎么理解偏向锁呢?

当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步
锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等
表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了

偏向锁的获取和撤销逻辑

  1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)
  2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord
    a) 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块
    b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
  3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
    a) 如果相等,不需要再次获得锁,可直接执行同步代码块
    b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

偏向锁的撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程
中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。

对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:

  1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程
  2. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块

偏向锁的关闭

Java6和Java7里是默认启用的,但是它在应用程序启动几秒钟之后才激活。可以通过JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。

在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking=false 来关闭偏向锁,那么程序默认进入轻量级锁状态。

偏向锁的获得和撤销流程图

在这里插入图片描述

轻量级锁的基本原理

轻量级锁加锁

线程执行同步代码块之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将锁对象头中的 markWord 信息复制到锁记录中,这个官方称为 Displaced Mard Word。然后线程尝试使用 CAS 将对象头中的 MarkWord 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示有其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

自旋锁

轻量级锁在加锁过程中,用到了自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。
注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无意义的循环反而能提升锁的性能。
但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

轻量级锁解锁

轻量级锁解锁时,会使用原子的CAS操作将 Displaced Mard Word替换回对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

轻量级锁及膨胀流程图

在这里插入图片描述

四、锁的优缺点对比

image-20210706230445947

[0] 锁的内容来自<<Java并发编程的艺术>>

以上是关于并发编程2_synchronized锁的主要内容,如果未能解决你的问题,请参考以下文章

并发编程2_synchronized锁

并发编程2_synchronized锁

并发编程__Lock 同步锁

Java并发编程--synchronized

Java多线程编程对象及变量的并发访问

5.并发编程-synchronized 细节说明