并发编程-2-并发编程带来的问题

本文主要内容:

  • 多线程访问共享变量的安全问题( 原子性 ,有序性 可见性)
  • java中的同步锁sychronized: sychronized基本使用,实现原理,锁升级的过程

1. 多线程访问共享变量带来的线程安全问题

package org.example.demo;

public class Test01 {
    public static int count=0;
    public static void incr(){
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;

    }
    public static void main( String[] args ) throws InterruptedException {
        for(int i=0;i<1000;i++){
            new Thread(()-> Test01.incr()).start();
        }
        Thread.sleep(3000); //保证线程执行结束
        System.out.println("运行结果:"+count);
    }
}

上面代码中,理论上最后应该输出1000,实际上每次运行结果都是一个不确定的小于等于1000的数
在这里插入图片描述
产生这种结果的原因有两点: 线程的可见性和 原子性 , 先撇开可见性,研究下原子性。事实上,count++ 虽然在java中是一条指令,但是在CPU层面上它是三条指令,对java来说,++操作不是原子操作(线程中的原子性体系在一系列的操作/指令一旦开始就不能被打断,这个指令是不可再分割的最小的指令单元),为什么java中的count++不是原子操作? 找到target目录下的Test01.class,open in terminal:
并发编程-2-并发编程带来的问题_第1张图片
然后输入以下命令:javap -v Test01.class ,就会得到这个类的字节码文件,找到incr()方法:
并发编程-2-并发编程带来的问题_第2张图片
关键在12~17行: getstatic是访问一个静态变量, putstatic是设置一个静态变量, iconst 1 是把常量1压入操作数栈,然后通过iadd指令进行递增操作,java中的++操作在CPU层面并不是一个指令来完成的,因此java中的++操作不是原子操作

	    12: getstatic     #5                  
        15: iconst_1
        16: iadd
        17: putstatic     #5 

在一开始的代码中,1000个线程都去执行i++操作,10000个线程是无法同时去执行的,线程之间一定会去抢占CPU的时间片,存在线程的上下文切换,实际的代码执行过程中的状态可能就变成了下面这样一种状态,导致输出结果不正确。
并发编程-2-并发编程带来的问题_第3张图片

2.Java如何保证线程并行的数据安全性

  线程本来的目的是为了并发处理任务,提升任务处理效率,如果是不同的线程去处理不同的对象,那么不存在问题,如果不同的线程访问到了同一个对象,就可能会产生问题了。在使用线程时,需要考虑到这样的场景所带来的影响。
  线程安全本质上是管理对于数据状态的访问,而且这个这个状态通常是共享的、可变的。共享,是指这个数据变量可以被多个线程访问;可变,指这个变量的值在它的生命周期内是可以改变的。
  一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及程序中是如何去使用这个对象的。如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。

线程并行导致的数据安全问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使得线程的并行变成串行,那是不是就不存在这个问题呢,锁就是这样一种实现。

  • Java中的同步锁Synchronized的作用范围
    synchronized 有三种方式来加锁,分别是
  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
    不同的修饰类型,代表锁的控制粒度,即锁的范围不同
 synchronized  void dmeo(){
    }

    Object obj = new Object();
    void demo2(){
        synchronized (obj){
            // 线程不安全操作
        }
    }

如果按照锁能够锁住的范围来分,锁可以分为 实例锁(实例锁就是对象实例)和类锁;

public class SynchronizedTest {
    void demo(){
        System.out.println(Thread.currentThread().getName());
    }
    public static void main(String[] args) {
        SynchronizedTest synchronizedTest1 = new SynchronizedTest();
        new Thread(()->{
            synchronizedTest1.demo();
        },"t1").start();
        new Thread(()->{
            synchronizedTest1.demo();
        },"t2").start();
    }
}

上面代码中两个线程都调用同一个实例 synchronizedTest1 的demo方法,这种场景能够保证锁的互斥性,t1获得锁并且未释放前,t2只能去等待,如果代码改成下面这样,两个线程调用不同的对象的实例方法,实际上是两把锁,两个线程之间不存在互斥性:

public class SynchronizedTest {
    void demo(){
        System.out.println(Thread.currentThread().getName());
    }
    public static void main(String[] args) {
        SynchronizedTest synchronizedTest1 = new SynchronizedTest();
        SynchronizedTest synchronizedTest2 = new SynchronizedTest();
        new Thread(()->{
            synchronizedTest1.demo();
        },"t1").start();
        new Thread(()->{
            synchronizedTest2.demo();
        },"t2").start();
    }
}

类锁:
下面代码中,SynchronizedTest 提供了静态方法demo2,并进行了加锁,当两个线程去访问demo2方法,它们之间也存在互斥性:

public class SynchronizedTest {
    static int i = 0;
    synchronized  static void demo2(){
        System.out.println(Thread.currentThread().getName() + "-" +i++);
    }
     public static void main(String[] args) {
        new Thread(()->{
            SynchronizedTest.demo2();
        },"t1").start();
        new Thread(()->{
            SynchronizedTest.demo2();
        },"t2").start();
    }
}

demo2与下面的写法等效,都属于类锁,类锁,对于该类的所有实例,都是互斥的

 static void demo3(){
        synchronized (SynchronizedTest.class){
            System.out.println(Thread.currentThread().getName() + "-" +i++);
        }
    }

类锁和对象锁体现的是在对资源进行加锁时,锁能够互斥的范围。 如果希望保护不同对象实例的同一个方法的话,那么就需要这几个对象持有同一把锁。

3. 锁如何存储

3.1 对象在内存中的布局

要实现多线程的互斥特性,那这把锁需要些因素?

  1. 锁需要有一个东西来表示,比如获得锁是什么状态、无锁状态是什么状态
  2. 这个状态需要对多个线程共享
    synchronized 锁是如何存储的呢?观察synchronized 的整个语法发现,synchronized(lock)是基于lock 这个对象的生命周期来控制锁粒度的,那是不是锁的存储和这个 lock 对象有关系呢?要弄清楚锁,首先要了解对象头。
    在 Hotspot 虚拟机中,对象在内存中的存储布局分为三个区域:对象头(Header)、实例数据(Instance Data)、对
    齐填充(Padding)
    并发编程-2-并发编程带来的问题_第4张图片
    补充知识: 对象的内存布局&hotspot对象模型
3.2 JVM中对象头的源码实现

   Java 代码中,使用 new 创建一个对象实例时(hotspot 虚拟机)JVM 层面实际上会创建一个instanceOopDesc 对象。
 Hotspot 虚拟机采用 OOP-Klass 模型来描述 Java 对象实例,OOP(Ordinary Object Point)指的是普通对象指针,Klass 用来描述对象实例的具体类型。
Hotspot 采用instanceOopDesc 和 arrayOopDesc 来 描述对象 头,arrayOopDesc 对象用来描述数组类型,instanceOopDesc 的定义在 Hotspot 源 码的instanceOop.hpp 文件中
并发编程-2-并发编程带来的问题_第5张图片
代码中可以看到 instanceOopDesc继承自 oopDesc,oopDesc 的定义在 Hotspot 源码oop.hpp 文件中,在普通实例对象中,oopDesc 的定义包含两个成员,分别是 _mark 和 _metadata

_mark : 表示对象标记、属于 markOop 类型,也就是 Mark World,它记录了对象和锁有关的信息

_metadata 表示类元信息,类元信息存储的是对象指向它的类元数据(Klass)的首地址,其中 Klass 表示普通指针、_compressed_klass 表示压缩类指针

3.3 Mark word 详解

在 Hotspot 中,markOop 的定义在 markOop.hpp 文件中,代码为:
并发编程-2-并发编程带来的问题_第6张图片

Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 种情况:
并发编程-2-并发编程带来的问题_第7张图片
并发编程-2-并发编程带来的问题_第8张图片

 static void demo3(){
        synchronized (SynchronizedTest.class){
            System.out.println(Thread.currentThread().getName() + "-" +i++);
        }
    }

线程去访问上面这段加锁的代码是,会先去查找锁住的对象SynchronizedTest 的对象头中锁的状态和信息,决定线程是否有资格能够访问这个加锁保护的资源。
在java中,可以通过openjdk提供的工具打印出类的布局:

 <dependency>
      <groupId>org.openjdk.jolgroupId>
      <artifactId>jol-coreartifactId>
      <version>0.10version>
    dependency>

编写这么一个类:

public class ClassLayoutDemo {

    public static void main(String[] args) {
        ClassLayoutDemo classLayoutDemo=new ClassLayoutDemo();
        System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
    }
}

接下来运行代码就可以打印这个类的布局信息(无锁状态下):
并发编程-2-并发编程带来的问题_第9张图片
16进制存储为: 00 00 00 00 01 00 00 01 把红框里面按照这样的顺序排列(大端存储和小端存储)
64位二进制:00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001
最后两位是01, 代表无锁,倒数第三位是0,代表不是偏向锁

注: 上面OFFSET 为偏移量,SIZE 为占用内存大小,单位字节,那么对象头一共是12个字节96位,是启用了压缩的结果,也可以把压缩关闭:-XX:-UseCompressedOops
并发编程-2-并发编程带来的问题_第10张图片
现在我们对程序进行加锁然后再打印:

public class ClassLayoutDemo {

    public static void main(String[] args) {
        ClassLayoutDemo classLayoutDemo=new ClassLayoutDemo();
        synchronized (classLayoutDemo){
            System.out.println("locking");
            System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
        }
    }
}

并发编程-2-并发编程带来的问题_第11张图片
然后锁的状态其实只需要看红色框里面(00011000)的最后三位就可以了,它的值是000,最后2位 00代表轻量级锁,
也就是上面的代码中默认加的是轻量级锁,并不是重量级锁。

  • 为什么任何对象都可以实现锁

(1)Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象,oop/oopDesc 进行对应
(2)线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的
Java 对象是天生携带 monitor。在 hotspot 源码的markOop.hpp 文件中,可以看到下面这段代码
并发编程-2-并发编程带来的问题_第12张图片
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个对象和线程争抢锁的逻辑有密切的关系。

3.4 Synchronized 锁升级的过程

  使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。有没有办法能够实现不加锁的情况下也能满足线程安全的要求呢?
  大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此在 synchronized 中,锁存在四种状态,分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态
根据竞争激烈的程度从低到高不断升级.

  • 偏向锁概念
    大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁。偏向锁,顾名思义,就是锁偏向于某个线程

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

jvm中偏向锁默认情况下是关闭的,可以通过下面的命令去打开:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

再次运行代码,查看打印出来的ClassLayoutDemo 类的布局信息:
并发编程-2-并发编程带来的问题_第13张图片
上图中红框中的101, 1代表获得的锁是偏向锁,01 是偏向锁 的状态。在只有一个线程访问同步代码块并且偏向锁是打开的情况下,获得的锁是偏向锁;如果偏向锁关闭,只有一个线程访问同步代码块,默认获得的是轻量级锁

  • 偏向锁的获取
  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. 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级
    为轻量级锁后继续执行同步代码块
    在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁
    的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁
    并发编程-2-并发编程带来的问题_第14张图片
    当两个线程同时尝试获取偏向锁,
    假设线程1先访问到了同步代码块
    ——》它会去检查对象头中是否存储了线程1的ID, 没有存的话,会通过CAS替换对象头,
    ——》替换成功,将对象头中的线程ID改为当前的线程ID, 并存储锁标记为 101(偏向锁)
    ——》线程1获得了偏向锁,线程2开始访问同步代码块
    ——》检查锁对象的对象头中是否存储了线程2, 没有的话,进行CAS替换
    ——》 替换不成功,撤销线程1获得的偏向锁:线程1执行结束或者到达了全局安全点safe point,则可以撤销成功,线程2获得偏向锁;撤销不成功的话,那么锁就升级到轻量级锁!
    注:偏向锁中不存在锁阻塞,是因为偏向锁的获取是使用的CAS
  • 轻量级锁的原理
    并发编程-2-并发编程带来的问题_第15张图片
    (1)两个线程同时访问同步代码块,假设线程1先获得轻量级锁
    (2)线程1会在线程的栈帧中分配一个lock record的空间,把对象头复制到lock record的空间,把当前的指针指向lock record,如下两图所示:
    并发编程-2-并发编程带来的问题_第16张图片
    并发编程-2-并发编程带来的问题_第17张图片
    (3)通过CAS修改指向lock record的指针,成功,表示获得轻量级锁,然后把修改锁状态为00
    (4)线程2尝试抢占锁,由于线程1已经获取锁,此时竞争失败,自动获取自旋锁(一般情况下,锁的获取和释放间隔时间非常短,因此线程2通过不断的尝试获取锁 必定优于通过阻塞来获取锁,因此这里获取锁竞争失败时,就自动获取一个自旋锁,也不是无限制重试)
    (5)线程2自旋失败,锁膨胀,修改为重量级锁
    锁升级的过程大体如下:并发编程-2-并发编程带来的问题_第18张图片
  • 重量级锁的加锁过程
    当轻量级锁膨胀到重量级锁之后,线程只能被挂起阻塞,等待被唤醒;加了同步代码块以后,在字节码中会看到一个
    monitorenter 和 monitorexit。每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器;monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
    并发编程-2-并发编程带来的问题_第19张图片
      如上图,任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

看如下代码,主线程和子线程共同抢占同一把锁,此时获取到的必然是重量级锁:

public class LockDemo {

    public static void main(String[] args) throws InterruptedException {
        LockDemo lockDemo=new LockDemo();
        Thread t1=new Thread(()->{
            synchronized (lockDemo){
                System.out.println("t1 抢占到锁");
                // 输出抢占到锁的状态
                System.out.println(ClassLayout.parseInstance(lockDemo).toPrintable());
            }
        });
        t1.start();
        synchronized (lockDemo){
            System.out.println("Main 抢占锁");
            System.out.println(ClassLayout.parseInstance(lockDemo).toPrintable());
        }
    }
}

运行结果如下:
并发编程-2-并发编程带来的问题_第20张图片
如果让主线程睡眠一会,结果又是怎样?
并发编程-2-并发编程带来的问题_第21张图片
运行,发现主子线程都获取的是轻量级锁,锁状态00 (偏向锁默认是关闭,未启用),这是因为两个线程之间隔了5秒,线程获取锁不存在竞争关系了,因此默认都获取到了轻量级锁
并发编程-2-并发编程带来的问题_第22张图片

补充知识:https://www.jianshu.com/p/e54415c529f0

  • 在打开偏向锁的情况下,计算一次hashCode,自动升级为重量级锁
package org.example;
import org.openjdk.jol.info.ClassLayout;
public class ClassLayoutDemo {
    public static void main(String[] args) {
        ClassLayoutDemo classLayoutDemo=new ClassLayoutDemo();
        synchronized (classLayoutDemo){
            System.out.println("locking");
            classLayoutDemo.hashCode(); 
            System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
        }
    }
}

这是因为偏向锁无法存储对象的hashCode, 就无法使用偏向锁了。
并发编程-2-并发编程带来的问题_第23张图片

你可能感兴趣的:(Java并发编程,多线程,java,jvm)