JAVA并发:线程安全与Synchorinzed

1. 什么是线程安全问题

线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核 cpu 以及超线程技术来实现线程的并行执行;第二个是线程的异步化执行相比于同步执行来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量。同时也带来了很多麻烦。如:多线程对于共享变量访问带来的安全性问题
一个变量 i,假如一个线程去访问这个变量进行修改,这个时候对于数据的修改和访问没有任何问题。但是如果多个线程对于这同一个变量进行修改,就会存在一个数据安全性问题。

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

public class SynchronizedDemo {

    private static int count = 0;

    private static void countIncr()  {
        try {
            TimeUnit.MILLISECONDS.sleep(10);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                countIncr();
            }).start();
        }
        // 睡眠5秒,确保线程执行结束
        TimeUnit.SECONDS.sleep(5);
        System.out.println("count_result:" + count);
    }
}

通过结果发现count_result有时为98或100,不是一个固定不变的值,和我们期望的结果不一样。这就是多线程对共享变量的读写带来的安全性问题。

2. 多线程的数据安全性

2.1 如何保证数据安全性

问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使线程并行变成串行去访问共享数据,这样就不存问题了。

我们可以通过加锁来保证共享数据的安全问题。锁是处理并发的一种同步手段,而如果需要达到前面我们说的一个目的,那么这个锁一定需要实现互斥的特性。

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

2.2 Synchroinzed

通过Synchroinzed解决前面例子出现的线程安全问题。

	// 加锁保证线程安全问题
    private synchronized static void countIncr()  {
        try {
            TimeUnit.MILLISECONDS.sleep(10);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        count++;
    }

synchronized实现同步的基础:java中的每一个对象都可以作为锁。具体表现为以下3种形式:

  • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  • 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

2.3 锁是如何存储的

为了实现多线程的互斥性,那么这把锁需要哪些东西呢?

  • 锁需要一个东西来表示。
  • 需要记录锁的状态(获得锁是什么状态,无锁是什么状态)
  • 锁状态需要对多个线程共享

synchroinzed(lock)是基于lock对象来控制锁的,因此锁和这个lock对象有关。因此我们需要关注对象在JVM内存中是如何存储的。

从JVM规范中可以看出Synchroinzed在JVM的实现原理,JVM进入和退出Monior对象来实现方法同步和代码块同步,两者的实现细节不一样但是,方法的同步同样可以使用这两个指令来实现。

2.4 JAVA对象头

在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

image-20200715232707602

Synchronized用的锁存在java对象头里的。

image-20200713230233355

java对象头里的Mark Word里默认存储对象的hashCode、分带年龄和锁标记位。32位JVM的Mark Word存储结构如下:

image-20200713230655649

在运行期间,Mark Word里面存储的数据会随着锁标志位的变化而变化。Mark Word可能变为存储以下4种数据

image-20200713230922739

可以看到,当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。

2.5 用户态和内核态

平时我们所写的java程序是运行在用户空间的,因为我们的jvm对于操作系统来讲就是一个普通程序。用户空间的程序要执行读写硬盘、读写网络、读写内存等重要操作时必须经过操作系统内核来进行。

在JDK早期,Synchronized是重量级锁,每次申请锁都需要调用系统内核。需要从用户空间切换到内核空间,拿到锁后再将状态返回给用户空间。

2.6 CAS原理

2.6.1 什么是CAS

Compare and Swap,即比较再交换。jdk5增加了并发包java.util.concurrent.*,其下面的类使用CAS算法实现了区别于synchronouse同步锁的一种乐观锁。JDK 5之前Java语言是靠synchronized关键字保证同步的,这是一种独占锁,也是是悲观锁。

2.6.2 CAS算法理解

CAS是一种无锁算法,CAS有3个操作数,内存值N,旧的预期值E,要修改的新值V。当且仅当预期值E和内存值N相同时,将内存值N修改为V。

存在ABA问题:一个线程把数据A变为了B,然后又重新变成了A。此时另外一个线程读取的时候,发现A没有变化,就误以为是原来的那个A。此问题可以加入版本号解决,每次更新内存值后加入一个版本号进行区分。

image-20200715233141405

2.7 锁的升级与对比

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

匿名偏向:锁对象线程ID为空,偏向锁的标识为1。

image-20200715221031149

2.7.1 偏向锁

Hotspot的作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

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

2.7.1.1 偏向锁的获取
  1. 首先获取锁对象的Markword,检查对象头中是否存储了当前线程的ID,如果存储了当前线程ID,表示当前线程已经获得了锁。

  2. 如果没有存储当前线程ID,锁对象处于可偏向状态(MarkWord中的偏向锁标识为1 且线程ID为空)。通过 CAS 操作,把当前线程的 ID写入到 MarkWord。

    • 如果 cas 成功,将对象头MarkWord的线程ID指向自己(变为T1|Epoch|1|01)。表示已经获得了锁对象的偏向锁,接着执行同步代码

      块。

    • 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁。

  3. 如果没有存储当前线程ID,锁对象处于已偏向状态(MarkWord中的偏向锁标识为1 且线程ID不为空)。当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

  4. 如果没有存储当前线程ID,且偏向锁标识为0,通过 CAS 操作,将对象头MarkWord的线程ID指向自己。

2.7.1.2 偏向锁的撤销

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

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

image-20200715232215020

2.7.1.3 关闭偏向锁

参考博客(https://blog.csdn.net/Epoch_Elysian/article/details/105519837)

偏向锁默认是启用的,但是它在应用程序启动几秒后才激活。如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定程序所有的锁通常情况下是处于竞争状态,可以通过JVM参数关闭偏向锁–XX:-UseBiasedLocking,那么程序默认会进入轻量级锁状态。

偏向锁为什么要延迟激活?

jvm在启动过程中是有大量的线程竞争资源的,这个时候启动偏向锁是没有意义的,所以延迟开启等待JVM启动。

openjdk提供了一个查看java对象布局的工具jol-core,来验证各个状态的MarkWord。注意关注锁标志位的变化

  1. JVM启动后创建对象

    此时偏向锁延迟开启还未启动,创建的对象为普通对象,加锁后直接变为轻量级锁。

    public class MarkWordDemo {
    
        public static void main(String[] args) throws InterruptedException {
            // 默认情况下偏向锁会延迟打开,此时偏向锁未启动
            Object object = new Object();
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
            System.out.println("-----------------");
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }
    }
    

    image-20200716002349395

  2. 延迟创建对象

    睡眠后创建对象,此时偏向锁已经开启,创建的对象为匿名偏向对象,加锁后为偏向锁。

    public class MarkWordDemo {
    
        public static void main(String[] args) throws InterruptedException {
            // 10后,偏向锁已启动
            TimeUnit.SECONDS.sleep(10);
            Object object = new Object();
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
            System.out.println("-----------------");
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }
    }
    

    image-20200716002725716

  3. 关闭延迟参数

    启动参数 -XX:BiasedLockingStartupDelay=0

    关闭偏向锁的延迟开启,创建的对象为匿名偏向对象,加锁后为偏向锁。结论和2相同

    public class MarkWordDemo {
    
        public static void main(String[] args) throws InterruptedException {
            // VM配置-XX:BiasedLockingStartupDelay=0关闭偏向锁的启动延迟
            Object object = new Object();
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
            System.out.println("-----------------");
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }
    }
    

    image-20200716003305913

  4. 关闭偏向锁

启动参数 -XX:-UseBiasedLocking,结论和1相同

public class MarkWordDemo {

    public static void main(String[] args) throws InterruptedException {
        // -XX:-UseBiasedLocking
        Object object = new Object();
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
        System.out.println("-----------------");
        synchronized (object) {
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
        }
    }
}

image-20200716003444083

2.7.2 轻量级锁

什么是轻量级锁

轻量级锁是当一个线程获取到该锁后,另一个线程也来获取该锁,这个线程并不会被直接阻塞,而是通过自旋来等待该锁被释放,所谓的自旋就是让线程执行一段无意义的循环。

为什么会引入轻量级锁呢

轻量级锁主要考虑到竞争线程并不多,并且持有对象锁的线程执行的时间也不长的这种情况,在未引入轻量级锁之前,如果一个线程刚刚被阻塞,这个锁就被其他线程释放,如果这种情况频繁发生,那么会因为频繁的阻塞以及唤醒线程给带来不必要的资源浪费。而在引入轻量级锁之后,在线程获取锁失败的情况下,线程并不会立即被阻塞,而是通过一段自旋的过程,来等待获取锁,因此就避免了频繁的阻塞与唤醒操作带来的资源浪费。

2.7.2.1 轻量级锁的加锁
  1. 当前线程在自己的栈桢中创建存储锁记录的空间 LockRecord.
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中官方称为Displaced mark 。
  3. CAS修改MarkWord
    • 成功:当前线程获得锁,并将markword修改为指向锁记录的指针
    • 失败:表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。
2.7.2.2 轻量级锁的解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。

image-20200717000707232

2.7.2.3 自旋锁

轻量级锁在加锁过程中,用到了自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无意义的循环反而能提升锁的性能。但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

2.7.3 重量级锁

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。

2.7.3.1 monitorenter与monitorexit
public class SynchronizedDemo {

    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {

        }
    }
}

javap -v SynchronizedDemo.class 获得字节码文件如下

image-20200716231713355

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权即尝试获取对象的锁。monitorexit 表示释放 monitor的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。

monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

image-20200716232530657

任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

2.7.3.2 ObjectMonitor
ObjectMonitor() {
      _header       = NULL;//markOop对象头
      _count        = 0;
      _waiters      = 0,   //等待线程数
      _recursions   = 0;   //重入次数
      _object       = NULL;
      _owner        = NULL;//指向获得ObjectMonitor对象的线程
      _WaitSet      = NULL;//处于wait状态的线程,会被加入到wait set;
      _WaitSetLock  = 0 ;
     _Responsible  = NULL ;
     _succ         = NULL ;
     _cxq          = NULL ;
     FreeNext      = NULL ;
     _EntryList    = NULL ;//处于等待锁block状态的线程,会被加入到entry set;
     _SpinFreq     = 0 ;
     _SpinClock    = 0 ;
     OwnerIsThread = 0 ;   // _owner is (Thread *) vs SP/BasicLock
     _previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
  }

其中ObjectMonitor以下几个成员变量需要重点关注

  • _owner:指向获得ObjectMonitor对象的线程

  • _EntryList: 处于等待锁block状态的线程,会被加入到entry set

  • _WiatSet: 处于wait状态的线程,会被加入到wait set(调用同步对象wait方法)

多个线程同时访问一段同步代码时,首先会进入_EntryList集合,进行阻塞等待, 当线程获取到对象的monitor后进入owner区域,并把monitor中的_owner变量指向该线程,同时monitor中的计数器count自加一,若线程调用同步对象的wait()方法将释放当前持有的monitor,_owner变量重置为null,count自减一,同时该线程进入_WaitSet中等待唤醒,线程执行完同步代码块后,也将_Owner和count变量重置.

3. 线程间通信

多个线程间相互配合工作,需要依靠线程间通信。在 Object对象中 , 提供了wait/notify/notifyall,可以用于控制线程的状态。

image-20200716233756471

需要注意的是:这些方法都必须在 synchronized 同步关键字所限定的作用域中调用 , 否则会报错java.lang.IllegalMonitorStateException。

代码Demo如下:

public class WaitNotifyDemo {

    private static Object object = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            synchronized (object) {
                System.out.println("ThreadA---Start");
                try {
                    // 释放锁等待
                    object.wait();
                    System.out.println("ThreadA---Wait");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (object) {
                System.out.println("ThreadB---Start");
                // 唤醒ThreadA,需要注意的是(ThreadB需要执行完同步代码)
                object.notify();
                System.out.println("ThreadB---Wait");
            }
        });

        threadA.start();
        TimeUnit.SECONDS.sleep(2);
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

输出如下:

ThreadA---Start
ThreadB---Start
ThreadB---Wait
ThreadA---Wait

image-20200716234542690

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值