Java并发

Java并发编程之所以需要专门设计是因为完全不进行控制的多个线程同时运行时,可能会出现与我们预期不符的结果。我们将线程安全定义为:线程安全指的是当多个线程访问同一代码不会产生不确定的结果。

原子性:一个操作不可再分割时,认为这个操作满足原子性,不满足原子性的操作会存在线程安全问题(满足原子性依然不能保证线程安全,因为还存在可见性的问题)

  • 读写满足原子性:byte/short/int/char/float/boolean;
  • 读写不满足原子性:long/double,它们需要经过两次32位操作;

可见性:当一个共享变量被某个线程修改后,其他线程能够立马读取到最新值,说明该变量具有内存可见性。

保证可见性的措施

  • 被final修饰的字段初始化后能保证可见性;(final会在赋值语句后面加一个写屏障,避免了final修饰的变量还没被赋值就被读取)
  • volatile修饰的字段保证可见性;
  • synchronized同步块内对共享字段的操作保证可见性;
  • 符合happens-before次序规定的可见性。

有序性:程序实际执行与代码编码时的顺序一致。

—保证有序性的措施

  • volatile:能够保证 在操作volatile修饰的变量 之前的写操作一定完成,之后的读操作一定在写操作之后。
  • synchronized:synchronized块里的满足有序性(基于happens-before的有序性,因为synchronized保证了一次只有一个线程执行,那么里面的指令重排是不会改变代码的逻辑的)。
  • happens-before法则。

1 volatile

1.1 volatile实现基础之内存屏障

volatile的功能有:保证变量的可见性(内存屏障使得变量修改后会立马刷新到主存,同时由于内存屏障的作用,volatile写操作前的所有操作修改的值也会被同步到主内存中)、禁止指令重排(volatile写操作前面的所有操作都不能排在volatile写操作后,)。

这些功能基于内存屏障实现,volatile内存屏障的设置如下(前面的store/load代表volatile写/读,后面的store/load代表普通写/读),简单理解是屏障前面和后面的操作重排不会越过屏障:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。(该写屏障前面写先于后面写)
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。(该写屏障前面的读先于后面的写)
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。(该读屏障前面读先于后面的读)
  • 在每个volatile读操作的后面插入一个LoadStore屏障。(该读屏障前面的读会先于后面的写)

有一个经典的例子,通常的说法下面的代码永远不会停,因为但其实不太严谨,因为这和具体的代码有关。

有这两种情况,以下代码能停止:

while循环里面加入了内存屏障(synchronized, volatile等),或者加入了println()语句。

主线程sleep()时间太短。(原因是在子线程的一开始还是会从主内存中读取值,当他发现好像go一直没变,他就只在自己工作内存里读了)

public class Test{
	static boolean go = true;
  public static void main(String[] args){
           new Thread(()-> {
            while (go) {
//                System.out.println("gogogo");
            }
        }).start();
    
        sleep(1000);
        go = false; 
  }
}

2 Synchronized(synchronized不属于JUC体系,JDK1.6后性能不比可重入锁差)

2. 0 锁粒度

作用于方法:锁对象是对象实例this,如果两个线程分别调用一个对象的两个不同的同步方法,会发生锁竞争(竞争this);

//下面两个synMethod不能同时执行

public class Main {
    public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        ConcurrentTest concurrentTest = new ConcurrentTest();
        new Thread(() -> {
            try {
                concurrentTest.synMethod1();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

        new Thread(() -> {
            try {
                concurrentTest.synMethod1();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

    }

}

class ConcurrentTest{
    synchronized void synMethod1() throws InterruptedException {
        System.out.println("synMethod1 start");
        sleep(2000);
        System.out.println("synMethod1 end");
    }

    synchronized void synMethod2() throws InterruptedException {
        System.out.println("synMethod2 start");
        sleep(2000);
        System.out.println("synMethod2 end");
    }
}

作用于静态方法:锁对象是Class实例,如果两个线程分别调用一个类的两个不同的静态同步方法,会发生锁竞争;

作用于某个对象:锁对象是这个对象,锁的范围是对应代码块。

2.1 synchronized锁升级

在JDK1.6之后,synchronized的加锁过程是:偏向锁->轻量级锁->重量级锁。从演化的过程来看,最初的锁只有重量级锁,而为了提高锁的性能在Java 6中对锁进行了优化。其中轻量级锁是为了减少不必要的阻塞和上下文切换,而偏向锁是为了降低轻量级锁在锁重入时频繁创建锁记录和CAS的所带来的不必要的开销。

1、轻量级锁比重量级锁更快的地方:

  • 轻量级锁的获取和释放更简单:轻量级锁只需要在栈帧中生成一个锁记录,并与锁对象交换markword即可完成加锁;而重量级锁需要去操作系统申请一个Monitor来加锁。
  • 轻量级锁可以避免线程阻塞和上下文切换带来的开销:重量级锁在遇到锁对象被占用时,会直接进入阻塞状态等待,而轻量级锁会保持住上下文状态进行自旋(即反复检查锁是否被释放),但如果某场景下线程竞争激烈,自旋反而会浪费性能。

2、偏向锁比轻量级锁更快的地方:

  • 锁重入时偏向锁无需加锁记录:在轻量级锁中,如果一个线程多次获取同一个锁对象,就需要在栈帧中多次添加锁记录,而偏向锁只需要查看对象头中的线程ID是否是自己,如果是自己就直接能进入。也就是说偏向锁省去了CAS的Swap开销。

2.2 偏向锁

触发场景:对象创建时,默认经过3秒延迟后进入偏向状态。

偏向锁的变化过程

1.锁撤销

  • 当调用obj的.hashcode()方法时,会使偏向锁失效。(一个对象的对象头最初默认是偏向锁状态,没有hashcode,直到调用hashcode()才会把线程ID替换成普通的对象头)
  • 当其他线程获取obj作为锁对象时,会撤销偏向锁,偏向锁升级为轻量级锁。
  • 当使用了wait()和notify()后,升级为重量级锁。(因为线程进入了阻塞状态)

2.批量重偏向:

频繁(超过20次)发生锁撤销时,偏向锁Thread ID会修改,详细来说:如果有一些objs偏向锁一开始的Thread ID是Thread-1,但后来都是Thread-2在使用(大于等于20次),且未发生锁竞争,那么偏向锁修改Thread ID为Thread-2。

3. 批量撤销:

当锁撤销超过40次时,JVM认为根本不该偏向谁,偏向锁就会被撤销。

4.锁消除:

当锁对象不会逃逸出某个方法时,即使使用synchronized(obj),也不会加锁(被优化掉了)。

2.3 轻量级锁

触发场景:当各线程获取锁无冲突时,或者有冲突,但在自旋尝试次数内能获取到锁时,使用轻量级锁。

加锁过程:

1.首次加锁:如果synchronized(obj)中的obj对象头为无锁状态(即锁标志位为“01”状态,偏向锁标记为“0”),当前线程的栈帧中会建立一个名为锁记录(Lock Record)的空间(锁记录结构见下图)。锁记录用于存储锁对象obj的Mark Word,和obj的引用;而obj的对象头被替换为锁记录的地址,并且锁状态变为 00。
在这里插入图片描述

2.重复加锁:如果同一个线程在加锁后,再次遇到synchronized(obj)会再生成一条锁记录(见下图),并且存储obj的引用,但不存obj的Mark Word。锁记录数可以用来当作锁重入的次数,每释放一次锁,就删掉一个锁记录,当需要删除的锁记录中存储了obj的Mark Word时,要重新交换Mark Word和锁记录地址。(如果发现obj对象头中存的不是锁记录的地址,说明发生了锁膨胀)
在这里插入图片描述

3.锁膨胀:当Thread-0已经给obj加了轻量级锁/重量级锁后,Thread-1想来给obj加轻量级锁,但是发现已经有人加了,那么此时Thread-1就会为obj申请一个Monitor,将Monitor的Owner设置为Thread-0,并且自己进入Monitor的EntryList中。
在这里插入图片描述

4.锁膨胀后的退出:Thread-0退出同步块后,根据Monitor的地址找到Monitor,将Owner设置为null,并将锁记录存储的MarkWord还给obj的对象头,并唤醒Monitor的EntryList。

2.4 Monitor(重量级锁)

触发场景:加轻量级锁时,锁对象已经被其他线程占用,且在自旋次数内没有获取到轻量级锁。
Monitor由操作系统提供,其结构如下:
在这里插入图片描述

当执行synchronized(obj)时,会把obj的对象头改成 指向一个Monitor的指针+锁状态10(不同锁对象会关联不同的Monitor,比如synchronied(otherObj)就不与synchronied(obj)共用一个Monitor,如果某个对象没有被synchronized那么不会关联Monitor)。

加锁过程

1.初始状态:Monitor初始时Owner为null;

2.首次加锁:当Thread-2执行synchronized(obj)时,Monitor的Owner设为Thread-2,并且Owner只能有一个;

3.其余线程欲加锁:比如Thread-3同样执行到synchronized(obj),它会先去看看对象头(已经被替换为Monitor的地址)指向的Monitor的Owner,发现Owner为Thread-2,只能去EntryList等待进入阻塞状态,如果还有其他的Thread来看,也会进入EntryList;

4.Owner释放了锁:Thread-2执行完synchronized(obj)里的代码后,EntryList中所有的Thread开始抢Owner,并不会排队;

5.Owner暂时离开:如果Thread-2只是使用了wait()暂时释放了锁,那么Thread-2会进入WaitSet;

3 JUC

3.1 Locks

3.1.0 AQS(AbstractQueuedSynchronizer)概述

AQS是一个抽象类,定义了一个同步器框架(ReentrantLock、Semaphore、CountDownLatch等都基于此实现),在留出一些可供开发者自行实现的方法同时帮助开发者完成了复杂的底层设计(比如锁的竞争,等待队列等)。AQS两个关键的方法是acquire和release,流程将在3.1.2和3.1.3中介绍。

AQS的总体设计是:

  • 通过一个volatile int state变量来标记当前线程是否被加锁。
  • 当尝试获取锁失败时,会将当前线程加入到CLH队列中。

继承AQS后,需要实现的方法根据需要实现的锁来确定(能够根据需求来选择实现哪些方法是因为AQS并没有将这些方法设置为抽象方法,妙!),所有需要实现的方法如下:

// 独占锁需要实现的  
tryAcquire(int)        // 尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)        // 尝试释放资源,成功则返回true,失败则返回false。
isHeldExclusively()    // 该线程是否正在独占资源。只有用到condition才需要去实现它。

// 共享锁需要实现的
tryAcquireShared(int)  // 尝试获取资源。返回值负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)  // 尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

3.1.1 AQS原理之节点状态waitStatus

结点(Node)是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。变量waitStatus则表示当前Node结点的等待状态,共有5种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。

  • CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
  • SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
  • CONDITION(-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
  • PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
  • 0:新结点入队时的默认状态。

注意,负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用>0、<0来判断结点的状态是否正常

3.1.2 AQS原理之acquire

acquire是AQS的获取锁的顶层方法,只需要调用acquire函数就能实现加锁(比如ReentrantLock的lock方法内就只有acquire(1)),acquire源码如下:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

acquire流程如下:

  1. tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
  2. addWaiter()根据锁的共享模式(Node.EXCLUSIVE表示独占模式)将该线程加入等待队列的尾部;
  3. acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回(由前一个节点叫醒)。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
    在这里插入图片描述

3.1.3 AQS原理之release

release是释放锁的顶层方法,调用一次release方法就能按照指定次数释放锁(一般认为当state被减到0时说明锁完全释放,但实际逻辑由用户在tryRelease中自定义)。release源码如下:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;//找到头结点
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);//唤醒等待队列里的下一个线程
        return true;
    }
    return false;
}

release流程如下:

  1. tryRelease释放指定次数锁,需要注意的是release根据tryRelease的返回值来确定锁是否被完全释放,因此在实现tryRelease时要留意这个条件;
  2. 如果锁已经完全释放,则用unpark()唤醒等待队列中最前边的那个未放弃线程;

在这里插入图片描述

3.2 Aotmic原子类

3.2.1 原理

aotmic类的主要用途就是把变量的非原子性操作整合为一个原子性操作,其内部原理是:

  1. 使用volatile修饰变量,使得变量修改后立马对其他线程可见;
  2. 修改变量的值:拿到需要操作的变量时,先记录初始值p_v,然后进行一系列用户希望的操作得到一个值n_v,使用CAS查看变量当前的值c_v是否还是原来的p_v,如果是就把n_v赋值给变量;(CAS操作使用的是Unsafe中的native方法,是原子性的,因此在CAS时不会出现线程安全问题)
  3. 修改引用的对象值:比如数组或者对象的某个字段,通过数组/对象地址加上要修改的目标的偏移量得到要修改的目标的地址,然后在这个地址上执行和“2.”相同的操作。

3.2.1 使用

aotmic类主要有AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference、AtomicMarkableReference、AtomicStampedReference、AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。

  • AtomicBoolean、AtomicInteger、AtomicLong、AtomicReference:这里用AtomicInteger举例,其他使用方法类似。使用getAndSet(int
    newValue)设置一个新的值,使用getAndUpdate(IntUnaryOperator
    updateFunction)传入一个函数式接口来进行复杂的更新操作(这里的IntUnaryOperator指的是接收一个int作为输入的函数式接口)。可能会有疑惑的是AtomicReference,其实把他看成一个存储指针的变量就行,里面值就是一个地址而已。
  • AtomicIntegerArray:getAndSet(int index, int newValue)和getAndUpdate(int index, IntUnaryOperator updateFunction)
  • AtomicIntegerFieldUpdater:想要原子地修改某个类的字段,需要给这个字段使用volatile修饰。然后通过如下方式更新:
        AtomicIntegerFieldUpdater<User> a = AtomicIntegerFieldUpdater.newUpdater(User.class, "age");
        User user = new User("Java", 22);
        System.out.println(a.getAndIncrement(user));// 22
        System.out.println(a.get(user));// 23 ```
    
  • AtomicStampedReference:Stamped的意思是印章、戳,这个atomic类主要是通过添加一个版本号用来解决ABA问题。
  • AtomicMarkableReference:只关心是否被修改过,而不关心修改了多少次。

3.3 Executor

3.4 Concurrent Colllections

4 线程协作

4.1 wait/notify

  • wait/notify的调用者是锁对象,比如synchronized(obj)中应该使用obj.wait()/obj.notify()/obj.notifyAll()。
  • wait/notify都需要在synchronized同步块中使用。
  • 调用wait后,当前线程进入WaitSet而不是EntryList。
  • 调用notify后,会唤醒其他Waitset中的线程进入EntryList准备抢锁,但锁不会立马释放,而是要等到notify所在同步块执行完。

4.2 park/unpark

  • park/unpark是LockSupport类的静态方法。
  • LockSupport.park()使得当前线程进入wait状态。
  • LockSupport.unpark(Thread
    t)使指定线程处于可唤醒状态。(注意是可唤醒状态,而不是唤醒,因为upark可以在park之前调用。具体来说,只要有人调用了unpark(t1),那么t1的可唤醒标识位就会置为1,而t1调用park()时会检查可唤醒标识位,如果是1就直接不进入wait状态,如果是0就需要等待可唤醒标志位置为1)

4.3 await/signal

  • await/signal是ReentrantLock中的Condition的实例方法。
  • await使用前需要通过ReentrantLock实例新建一个Condition实例对象。(condition对象可以有很多个,线程可以选择进入哪个condition,condition用来存等待线程)
  • signal类似于notify,只不过notify的调用者是锁对象,signal的调用者是Condition对象,signal同样只能唤醒一个等待线程进入队列竞争锁。

4.4 join

  • join通过Thread实例调用,如t.join(),会阻塞当前线程直到t线程完成。
  • join还提供了等待时间的设置,当超过等待时间后,会终止等待。

查漏补缺

自旋比阻塞开销小原因?

  • 阻塞会涉及到上下文切换,其中包括保存当前线程的寄存器状态、程序计数器等信息,并加载另一个线程的上下文信息,而自旋不发生上下文切换;
  • 线程的阻塞和唤醒通常需要操作系统处理线程的调度和资源分配,这些操作比简单的CPU指令执行(自旋)开销更大;
  • 但是,自旋效率更高的前提是使用的多核CPU,如果是单核CPU,想获取其他线程的资源就必须进行上下文切换;此外如果线程竞争激烈,CAS就会反复做无用功,反而影响效率。

实际应用

如何解决缓存击穿?

  • 设置缓存永不过期,但存在数据一致性问题;
  • 缓存过期前,使用一个线程去数据库查数据,并提前更新缓存;
  • 缓存过期后,使用一个线程去更新缓存。

最后一种方法是当发生缓存击穿后,每个服务器只使用一个线程去数据库查数据,其他线程都从这个线程拿回去。

下面的loadShop实现了上述功能:

1、准备一个ConcurrentHashMap来存储商品ID和查询数据库的操作。

2、如果缓存中没有查到数据,那么生成一个查询数据库的操作。

3、尝试把商品ID作为Key,查询数据库的操作作为Value放入ConcurrentHashMap中。

4、放得进去说明还没人去取数据,那么就当前线程去取;放不进去说明有人去取了,只需等待数据。
在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值