并发编程三大利器

前言

并发三大利器

Q:既然有了Synchronized关键字做并发,为什么还会有Lock包?

Synchronized缺点(与Lock包区别):
1.无法判断锁状态,不知道当前锁是否锁住还是没有锁住
2.不可中断,如果线程迟迟拿不到一把锁,这把锁被其他线程占用,会出现该线程一直等待
3.Synchronized实现的是非公平锁,任何新线程过来与原先排队等待的线程都有同样的机会获得锁
4.关键字实现获取锁与释放锁,中间不可控
5.当锁产生竞争时,两个和三个线程争取一把锁,没有获取到,此时会升级为重量级锁,改由系统主动分配,此时会造成用户空间切换到内核空间

一、Synchronized

(一)Synchronized底层实现

在理解锁实现原理之前先了解一下Java的对象头和Monitor,在JVM中,对象是分成三部分存在的:

  • 对象头
  • 实例数据
  • 对齐填充
    在这里插入图片描述

实例数据与对齐填充与synchronized无关。

1.1Java对象在对内存分布

  1. 实例数据存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;对齐填充不是必须部分,由于虚拟机要求对象起始地址必须是8字节的整数倍,对齐填充仅仅是为了使字节对齐。
  2. 对象头是我们关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。
  3. 对象头主要结构是由Mark WordClass Metadata Address 组成,其中Mark
    Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息,Class Metadata
    Address是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例。

1.2锁的不同状态

JDK6之前只有两个状态:无锁、有锁(重量级锁)
而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:

  1. 无锁状态
  2. 偏向锁
  3. 轻量级锁
  4. 重量级锁

其中无锁也是一种状态。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。
每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

_header = NULL;
_count = 0; //锁计数器
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ; }

1.3 ObjectMonitor 工作机制

ObjectMonitor中有两个队列:
_WaitSet , 等待锁的线程集合
_EntryList,进入锁范围的线程集合

1.4 监视器工作流程

1.用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象)
2.owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时, 首先会进入**_EntryList 集合**
3.当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1
4.若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。
5.若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

monitor对象存在于每个Java对象的对象头中(存储指针的指向)

2. synchronized 原理

Q: 为什么Java中任意对象可以作为锁?

因为 synchronized锁可以把任意对象作为锁,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

2.1 JVM对synchronized的优化

从最近几个jdk版本中可以看出,Java的开发团队一直在对synchronized优化,其中最大的一次优化就是在jdk6的时候,新增了两个锁状态,通过锁消除、锁粗化、自旋锁等方法使用各种场景,给synchronized性能带来了很大的提升。

2.2 锁升级

上面讲到锁有四种状态,并且会因实际情况进行膨胀升级,其膨胀方向是:
无锁——>偏向锁——>轻量级锁——>重量级锁,并且膨胀方向不可逆。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.3 偏向锁

一句话总结它的作用:减少同一线程获取锁的代价。在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,那么此时就是偏向锁。
核心思想:

  • 如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark
    Word的结构也就变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark
    Word的锁标记位为偏向锁以及当前线程ID等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。
    过程: 先说无锁—>偏向锁。锁的标志位都为01。
  • 当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark
    Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
  • 要注意的是,偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

2.4 轻量级锁

轻量级锁是由偏向锁升级而来,当存在第二个线程申请同一个锁对象时,偏向锁就会立即升级为轻量级锁。注意这里的第二个线程只是申请锁,不存在两个线程同时竞争锁,可以是一前一后地交替执行同步块。

2.5 重量级锁(独占锁/排它锁)

重量级锁是由轻量级锁升级而来,当同一时间有多个线程竞争锁时,锁就会被升级成重量级锁,此时其申请锁带来的开销也就变大。
重量级锁一般使用场景会在追求吞吐量,同步块或者同步方法执行时间较长的场景。
重量级锁会将获取到锁的当前线程以外的线程全部挂起,所以重量级锁也被称为独占锁,或排它锁。

2.6 自旋锁与自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

**自旋锁:**许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。但是它也存在缺点:如果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。

自适应自旋锁:这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

2.7 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。

2.8 锁粗化

锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。

二、Atomic包

(一)Volatile

指令重排序

指令加载较慢,指令优化后,执行引擎可以多核同时加载Arm指令。
规则:在并发执行情况下,如果造成结果不一致(发生二义性),则一定不会进行指令重排序;结果一致,则有可能进行指令重排。

Volatile的作用:

用来修饰变量,保证其:
1)有序性(防止重排序)
2)实现可见性

volatile使用

例:DCL单例,双重锁检查给变量加volatile

package com.maniu.classdemo;

public class Singleton {
    public  volatile  static  Singleton singleton;
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton) {
                if (singleton == null) {
                    singleton = new Singleton();
                    //new实现为多条指令    ====》 new-instance
                    //如过不用volatile修饰singleton,此处并发情况下会导致singleton为空,
                    //因为指令重排,获取锁的当前线程可能还没new出来,其它线程读取为null
                }
            }
        }
        return singleton;
    }
 }

转换后的字节码文件:
在这里插入图片描述

Volatile的原理:

1.修改volatile变量后会强制将修改后的值刷新到主内存中。
2.修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候,就需要重新读取主内存中的值。
具体实现:
在这里插入图片描述

1.使用volatile关键字会强制将修改的值立即写入主内存;
2.使用volatile关键字,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)
3.由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

volatile无法保证原子性

在这里插入图片描述
i++是一个复合操作,中间步骤可能被其它线程打断。所以通过volatile修饰的变量i,进行i++操作,无法保证原子性。但可以通过CAS保证其原子性。

(二) CAS

1.CAS实现

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。
CAS是一种无锁算法(lock-free 机制的基础)。有3个操作数:内存值V、旧的预期值A、要修改的新值B。

当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。

该操作是一个原子操作,被广泛的应用在Java的底层实现中。在Java中,CAS主要是由sun.misc.Unsafe这个类通过JNI调用CPU底层指令实现。

2.CAS的开销

CAS速度非常快:

  1. CAS是CPU指令级的操作,只有一步原子操作;
  2. CAS避免用户态和内核态了无需请求操作系统来裁定锁,直接在CPU内部就搞定了

CAS的缺点有以下两个方面:

  1. 循环时间长开销大
  2. ABA问题
循环时间长开销大

CAS仍然可能有消耗:可能出现cache miss的情况,会有更大的cpu时间消耗。
在这里插入图片描述

ABA问题

ABA:如果另一个线程修改V值假设原来是A,先修改成B,再修改回成A。当前线程的CAS操作无法分辨当前V值是否发生过变化。

ABA问题解决办法
通过加入版本号控制

3.AtomicInteger

java的并发原子包里面提供了很多可以进行原子操作的类,比如:
AtomicInteger
AtomicBoolean
AtomicLong
AtomicReference
提供这些原子类的目的就是为了解决基本类型操作的非原子性导致在多线程并发情况下引发的问题,以AtomicInteger为例。

AtomicInteger概述

AtomicInteger的本质:自旋锁+CAS原子操作。
AtomicInteger位于java.util.concurrent.atomic包下,是对int的封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS。

AtomicInteger使用

应用场景:
AtomaticInteger最典型的应用场景是计数。比如我们要统计并发插入10万条数据的耗时,我们需要对插入的数据计数,普通的int变量在多线程环境下的++操作,是线程不安全的,前一个操作可能会被后一个操作所覆盖,所以统计的技术往往小于准确值。这时候就可以使用AtomaticInteger。

非原子性的操作会引发什么问题呢?可通过一个示例来看一下。

1.i++引发的问题

基本类型的赋值操作是原子操作,但是类似i++的复合操作并不是原子操作,通过字节码文件我们大致了解此操作分为三个阶段:

tp1 = i;  //1
tp2 = tp1 + 1;  //2
i = tp2;  //3

如果有两个线程m和n要执行i++操作,因为重排序的影响,代码执行顺序可能会发生改变。如果代码的执行顺序是m1 -> m2 -> m3 -> n1 -> n2 -> n3,那么结果是没问题的,如果代码的执行顺序是m1 -> n1 -> m2 -> n2 -> m3 -> n3那么很明显结果就会出错。
示例代码

package com.yx.lib;

public class AtomicIntegerTest {
    private static int n = 0;

    public static void main(String[] args) throws InterruptedException {
        //i++引发的线程问题
        Thread t1 = new Thread() {
            public void run() {
                for(int i = 0; i < 1000; i++) {
                    n++;
                }
            };
        };
        Thread t2 = new Thread() {
            public void run() {
                for(int i = 0; i < 1000; i++) {
                    n++;
                }
            };
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终n的值为:" + n);
    }
}

三次运行结果:
在这里插入图片描述
如果i++是原子操作,那么结果应该就是2000,反复运行几次发现结果大部分情况下都不是2000,这也证明了i++的非原子性在多线程下产生的问题。当然我们可以通过加锁的方式保证操作的原子性,但出于性能考虑并无必要。

2. AtomicInteger的原子操作

上面的问题可以使用AtomicInteger来解决,更改一下代码如下:

package com.yx.lib;

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerTest {

    private static AtomicInteger n2 = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread() {
            public void run() {
                for(int i = 0; i < 1000; i++) {
                    n2.incrementAndGet();
                }
            };
        };
        Thread t2 = new Thread() {
            public void run() {
                for(int i = 0; i< 1000; i++) {
                    n2.incrementAndGet();
                }
            }
        };
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("最终n2的值为:" + n2.toString());
    }
}

多次运行,发现结果永远是2000,
在这里插入图片描述
由此可以证明AtomicInteger的操作是原子性的。那么AtomicInteger是通过什么机制来保证原子性的呢?接下来,我们对源码进行一下分析。

AtomicInteger源码分析
public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;
}
  • 从 AtomicInteger 的内部属性可以看出,它依赖于Unsafe提供的一些底层能力,进行底层操作;如根据valueOffset代表的该变量值在内存中的偏移地址,从而获取数据的。
  • 变量value用volatile修饰,保证了多线程之间的内存可见性。
1.1 JDK1.7及以前
public final int incrementAndGet() {
    //自旋锁
    for (;;) {
        //获取volatitle修饰的变量,最新的主存值
        int current = get();
        //理论上自增值
        int next = current + 1;        
        if (compareAndSet(current, next))
            return next;
    }
}
  1. 获取volatitle修饰的变量,最新的主存值

  2. value+1作为自增值

  3. compare value是否就是主存值,是,set next,return next;否,循环下一次

1.2 JDK1.8及以后
 /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

下面看getAndAddInt方法:

public final int getAndAddInt(Object obj, long valueOffset, int delta) {
        int expect;
        //自旋
        do {
            //获取主存的值
            expect = this.getIntVolatile(obj, valueOffset);
        //CAS操作
        } while(!this.compareAndSwapInt(obj, valueOffset, expect, expect + delta));
 
        //返回旧值
        return expect;
    }
  1. 封装了自旋锁
  2. 直接封装unsafe方法中了,保证原子性,unsafe是JDK私有的我们不能调用

下面以getAndIncrement为例,说明其原子操作过程:

 public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);//getAndAddInt方法实现看上面一节
    }
  • 假设线程1和线程2通过getIntVolatile拿到value的值都为1,线程1被挂起,线程2继续执行
  • 线程2在compareAndSwapInt操作中由于预期值和内存值都为1,因此成功将内存值更新为2
  • 线程1继续执行,在compareAndSwapInt操作中,预期值是1,而当前的内存值为2,CAS操作失败,什么都不做,返回false
  • 线程1重新通过getIntVolatile拿到最新的value为2,再进行一次compareAndSwapInt操作,这次操作成功,内存值更新为3

参考:
https://www.jianshu.com/p/cea1f9619e8f
https://zhuanlan.zhihu.com/p/142274325

三、Lock包

CAS理论实现优秀的并发,性能超过synchronized

(一)ReentrantLock

ReentrantLock使用

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockTest extends Thread{
    public static ReentrantLock lock = new ReentrantLock(true);
    public static int i = 0;

    public ReentrantLockTest(String name) {
        super.setName(name);
    }

    @Override
    public void run() {
        for(int j = 0; j < 100000; j++){
            lock.lock();
            try {
                System.out.println(this.getName() + " " + i);
                i++;
            } catch (Exception e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }
    }

    public synchronized static void main(String[] args) throws InterruptedException {
        ReentrantLockTest test1 = new ReentrantLockTest("thread1");
        ReentrantLockTest test2 = new ReentrantLockTest("thread2");
        test1.start();
        test2.start();
        test1.join();
        test2.join();
        System.out.println(i);
    }
}

运行结果:
在这里插入图片描述

ReentrantLock原理

ReentrantLock底层使用了CAS+AQS队列实现,ReentrantLock的基本实现可以概括为:先通过CAS尝试获取锁。如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起。当锁被释放之后,排在同步队列(CLH)队首的线程会被唤醒,然后CAS再次尝试获取锁。

1.reentrantlock类图结构

在这里插入图片描述
在这里插入图片描述
可以看到在reentrantlock中组合了一个AQS属性(即sync),通过reentrantlock加锁解锁等相关操作其实都是通过调用AQS同步器相关方法来实现的。所以分析ReentrantLock需要掌握AQS。

2.AQS

是一个用于构建锁和同步容器的框架。事实上concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的大量细节问题。
AQS使用一个先进先出(FIFO,也叫CLH队列,是CLH锁的一种变形)队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。在这里插入图片描述

3.获取锁的过程

非公平锁和公平锁获取锁流程区别:
1.非公平锁,如果有其他线程尝试lock(),有可能被其他刚好申请锁的线程抢占。
2.公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

ReentrantLock默认是非公平锁,以非公平锁为例:
在这里插入图片描述
通过AQS中state字段表示锁是否被占有(0表示当前没有任何线程占用锁,1表示该锁被某个线程占用)
占用锁成功, 更新state状态,并设置独占线程为当前线程。

4.总结

0.每一个ReentrantLock自身维护一个AQS队列记录申请锁的线程信息;

1.通过大量CAS保证多个线程竞争锁的时候的并发安全;

2.可重入的功能是通过维护state变量来记录重入次数实现的。

3.公平锁需要维护队列,通过AQS队列的先后顺序获取锁,缺点是会造成大量线程上下文切换;

4.非公平锁可以直接抢占,所以效率更高;

5.源码分析

参考:
https://zhuanlan.zhihu.com/p/249147493 简洁
https://blog.csdn.net/fuyuwei2015/article/details/83719444 流程清晰
https://blog.csdn.net/Hellowenpan/article/details/117884171 图到位

6.LockSupport

LockSupport.park() :进入休眠
LockSupport.unPark(thread1) :唤醒sleep休眠中的线程

7.手写实现ReentrantLock

YXReentrantLock.java

package com.yx.lib;

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;

public class YXReentrantLock {
    private final AtomicBoolean locked = new AtomicBoolean();
    private Queue<Thread> waiters = new ConcurrentLinkedQueue<>();

    public void lock(){
        Thread current = Thread.currentThread();
        waiters.add(current);

        while (waiters.peek() != current || !locked.compareAndSet(false, true)){
            //进来了 假设 休眠
            LockSupport.park(this);
        }
        waiters.remove();
    }
    public void unlock(){
        locked.set(false);
        LockSupport.unpark(waiters.peek());
    }
}

YXReentrantLockMain.java

package com.yx.lib;

public class YXReentrantLockMain {
    static int ii = 0;
    static YXReentrantLock lock = new YXReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        for(int i = 0; i < 5; i++){
            Thread tt = new Thread(){
                @Override
                public void run() {
                    for(int i = 0; i < 1000000; i++){
                        //不会被阻塞住
                        lock.lock();
                        ii++;
                        lock.unlock();
                    }
                }
            };
            tt.start();
            tt.join();
        }
        System.out.println(ii);
    }
}

运行结果如下:
在这里插入图片描述
多次反复运行结果均是5000000,说明YXReentrantLock实现了并发访问同步效果。

参考

https://blog.csdn.net/qq_36934826/article/details/95978700 synchronized锁

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值