java并发编程学习

目录

前言

Synchronized

Synchronized同步静态方法与非静态方法的区别

公平锁和非公平锁

偏向锁、轻量级锁和重量级锁

可重入锁和不可重入锁

基于等待/唤醒的可重入和不可重入锁

自旋锁的可重入与不可重入

Atomic原子类

CAS比较交换

CountDownLatch

简介

使用案例

源码分析

Fork/Join

框架简介

使用案例

ConcurrentHashMap


前言

本文准备记录jdk1.5之后出现的concurrent包目录下的各类并发Api以及相关并发编程知识,互相勉励。另也给出之前总结过的部分多线程知识作为本文补充:多线程知识总结   争取日后持续更新本文并发知识

 

Synchronized

这是最常见的一种用于解决线程安全的可重入锁,是jvm层面的锁,其相比于ReentrantLock锁最大的特点是完全由jvm帮你管理锁,当锁定代码抛出异常时jvm会自动帮你释放锁。synchronized大致用在两个地方,方法及代码块,当作用在非静态方法和代码块时,其本质锁的就是当前对象,当作用在静态方法上时,其锁的是class对象也就是当前类,而class对象只有一份,可以理解为任何时候只有一把钥匙,不管多少人想进这个class对象空间,都需要排队拿钥匙。

这里主要介绍下锁在非静态方法上的情况:

java中每一个对象都可以成为一个监视器(Monitor), 该Monitor由一个锁(lock), 一个等待队列(waiting queue ), 一个入口队列( entry queue)组成,当我们锁住某个方法时,其本质锁的还是当前对象即this,jvm会给该对象加一个同步监视器,这样每次获取或执行该对象时都可以被监视,可以认为是监视该对象的操作,一旦执行到被synchronized锁定的方法则必须要阻塞等待,而那些非同步方法则允许被执行。

Synchronized同步静态方法与非静态方法的区别

下面举例子来说明,各场景模拟均展示在注释中:

public class SynchronizedTest {

    /**
     * 控制同一个静态对象
     */
    public static SynchronizedTest synchronizedTest = new SynchronizedTest();

    /**
     * 非静态方法加锁1
     */
    public synchronized void method1() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Current thread:" + Thread.currentThread().getName() + ", method1 is running...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 非静态方法加锁2
     */
    public synchronized void method2() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Current thread:" + Thread.currentThread().getName() + ", method2 is running...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 静态方法加锁1
     */
    public static synchronized void staticMethod1() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Current thread:" + Thread.currentThread().getName() + ", staticMethod1 is running...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 静态方法加锁2
     */
    public static synchronized void staticMethod2() {
        for (int i = 0; i < 5; i++) {
            System.out.println("Current thread:" + Thread.currentThread().getName() + ", staticMethod2 is running...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 测试主方法
     * @param args
     */
    public static void main(String[] args) {
        new Thread(new Thread1()).start();
        new Thread(new Thread2()).start();
    }

}

/**
 * 两个测试线程,模拟不同锁方法的获取锁示例,两者一行一行对应模拟不同的场景
 * 通过释放注释来达到不同的效果
 */
class Thread1 implements Runnable {
    @Override
    public void run() {
        // 第一个场景:同一个对象调用同一个非静态方法  结果:发生互斥,synchronized锁的是当前对象,因此在访问该对象的任何同步方法时一定是互斥的
        // SynchronizedTest.synchronizedTest.method1();

        // 第二个场景:同一个对象调用两个不同的非静态方法  结果:发生互斥,同第一个场景,注意非静态方法是对象锁。
        // SynchronizedTest.synchronizedTest.method2();

        // 第三个场景:不同对象分别调用同一个非静态方法   结果:不会互斥,因为是两个不同的对象,锁的主体都不同了
        // new SynchronizedTest().method1();

        // 第四个场景:不限制同一对象,分别调用非静态方法与静态方法  结果:不会互斥,静态方法由于锁的是当前类对象即class对象,而非静态方法锁的是当前
        // 对象即this对象,两者锁的类型不一样,换句话说调用的主体也不一样,静态方法实际上是类对象在调用,因此两者并不会互斥,会并发进行
        // SynchronizedTest.synchronizedTest.method2();

        // 第五个场景:使用类直接调用两个不同的静态方法   结果:发生互斥,因为两个线程调用的都是静态方法,而静态方法锁的是当前类对象,而class
        // 只有一份,可以理解为任何时候只有一把钥匙,不管多少人想进这个class对象空间,都需要排队拿钥匙
        // SynchronizedTest.staticMethod1();

        // 第六个场景:从第四个场景衍生,使用同一个对象分别调用非静态方法与静态方法 结果:不会互斥,还是因为锁的是不同类型,与同一对象无关
        // ps:调用静态方法时不要通过静态对象去调用,使用类实例即可,这边只是为了测试...
        SynchronizedTest.synchronizedTest.method2();
    }
}

class Thread2 implements Runnable {
    @Override
    public void run() {
        // 一
        // SynchronizedTest.synchronizedTest.method1();

        // 二
        // SynchronizedTest.synchronizedTest.method1();

        // 三
        // new SynchronizedTest().method1();

        // 四
        // SynchronizedTest.staticMethod1();

        // 五
        // SynchronizedTest.staticMethod2();

        // 六
        SynchronizedTest.synchronizedTest.staticMethod1();
    }
}

总结下:

1、synchronized作用在静态方法上时锁的是当前类对象,即class对象,调用该实例的任何同步静态方法时都会互斥,与synchronized (xx.class) {} 效果是一样的

2、synchronized作用在非静态方法上时锁的是当前对象,即this对象,因此访问同一对象的任何同步方法都会阻塞,这里尤其要注意锁针对的不是方法!

 

公平锁和非公平锁

ReentrantLock锁的实现内部分为两种锁,即公平锁和非公平锁,其默认使用非公平锁,主要也是为了性能考虑,下面简单介绍下

整体来看:

公平锁:线程获取锁的顺序和调用lock的顺序一样,FIFO;
非公平锁:线程获取锁的顺序和调用lock的顺序无关,全凭运气。

ReentrantLock内部主要是采用一个state计数器来判定,

非公平锁:当某个线程来获取锁时,首先判定当state等于0时说明当前锁未被占用,当发现当前锁未被占用时可直接取锁,无需进到队列。

公平锁:当某个线程来获取锁时,首先判断锁是否被占用,如果发现当前锁未被占用,然后判断当前队列中是否还有线程在等待,如果有则取出队列头线程,当前线程进入队列尾阻塞等待。

 

偏向锁、轻量级锁和重量级锁

这三种其实对应于三种锁分配策略以及锁的不断膨胀过程,换句话说前两种也是一定意义上的锁优化。说实话,这三种锁的核心就是关于在竞争锁的过程中线程从挂起到恢复的这一段性能问题,基本都是围绕着这块的区别。下面简单的做个入门认识:

重量级锁:内置锁(Synchronized)在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

轻量级锁:与重量级锁相对应,典型锁代表则为自旋锁和自适应自旋锁,其主要目标就是降低线程切换所带来的成本,一般就是通过CAS操作来修改对象头中的MarkWord锁信息,如果能更新成功,说明轻量级锁获取成功;如果更新失败,说明存在锁竞争,接下来就会膨胀成重量级锁,不过由于轻量级锁瞄准的都是锁竞争不那么激烈的,所以一般都会先通过自旋来等待,自旋失败后再膨胀。

偏向锁:“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。

 

可重入锁和不可重入锁

理解这两个概念对于阅读ReentrantLock源码有很好的帮助

可重入锁:指可重复可递归调用的锁,在外层方法使用锁之后,在内层仍能获取锁,并且不发生死锁(前提是处于同一线程下),

ReentrantLock和synchronized两类锁机制都属于可重入锁

不可重入锁:当前线程在执行某个方法时获取了锁,在该方法内部尝试再次获取锁时,会因为无法获取锁而阻塞

这里我们以不可重入锁为切入点来更好的理解这两种锁机制,下面从两个方向来举例

基于等待/唤醒的可重入和不可重入锁

package concurrent;

/**
 * Created by xujia on 2019/5/29
 */
public class Lock {

    /**
     * 标识锁状态
     */
    private boolean isLocked;

    public synchronized void lock() throws InterruptedException{
        while (isLocked)
            wait();
        isLocked = true;
    }

    public synchronized void unlock() {
        isLocked = false;
        notify();
    }

}

然后写一个调用方来使用该Lock:

package concurrent;

import org.junit.Test;

/**
 * Created by xujia on 2019/5/29
 */
public class People {

    private Lock lock = new Lock();

    public void say() throws InterruptedException{
        lock.lock();
        System.out.println("i'm saying");
        eat();
        lock.unlock();
    }

    public void eat() throws InterruptedException{
        lock.lock();
        System.out.println("i'm eating");
        lock.unlock();
    }

    @Test
    public void test() {
        try {
            say();
        } catch (Exception e) {

        }
    }
}

当运行这段代码时,由于进入say方法时已经拿过一遍锁,此时再次执行eat,会出现由于获取不到锁而造成当前线程阻塞,这就是不可重入锁。这种锁其实是不可取的,在同一线程下理应不需要执行过多的获取锁释放锁操作,这会造成不必要的性能开销。下面我们来改进下该Lock锁:

package concurrent;

/**
 * Created by xujia on 2019/5/29
 */
public class Lock {

    /**
     * 标识锁状态
     */
    private boolean isLocked;
    /**
     * 当前线程对象
     */
    private Thread thread;
    /**
     * 标识重入次数
     */
    private int count;

    /**
     * witf和notify方法必须与synchronized一同使用,为了避免某些场景,即:
     * 当不保证同步时,代表任意线程可以在任意时刻调用,线程1在还未执行wait方法时,线程2已经执行完notify了,此时线程1才执行wait
     * 如果没有其他线程再次执行notify,线程1将永远等待。
     *
     * 可以简单的认为synchronized锁对象维护了一个等待队列,当执行了wait方法将被放入等待队列中,当另一个线程执行notify时,会唤醒等待队列中的线程
     * @throws InterruptedException
     */
    public synchronized void lock() throws InterruptedException{
        Thread curThread = Thread.currentThread();
        while (isLocked && curThread != thread)
            wait();
        count ++;
        isLocked = true;
        thread = curThread;
    }

    public synchronized void unlock() {
        if (Thread.currentThread() == thread) {
            count --;
            if (count == 0){
                isLocked = false;
                notify();
            }
        }
    }

}

此时上述代码已经变成可重入锁了,支持在方法内部再次获取锁,这也是可重入锁的核心思想。再次运行测试类,会发现运行已通过,结果如下:

i'm saying
i'm eating

Process finished with exit code 0

下面我们来分析下上述的调用过程:

假设此时有两个线程1和2,线程1执行say方法时,执行lock加锁操作,由于当前锁并未被占用即isLocked为false直接拿到锁,并将当前线程对象赋予变量thread同时count++,然后接着执行eat方法,再一次尝试获取锁,由于当前线程对象相等,因此无需阻塞count++,此时count=2,假设线程2此时进来执行say,由于满足while条件直接阻塞,直到线程1执行两次unlock操作释放锁,线程2才能继续执行。

ReentrantLock的源码也是采用这种思想,但它是基于CAS算法来比较,即接下来要讲的自旋锁,ReentrantLock内置有一个state标识,只有当state标识为0时才会释放锁,具体解读之后空了会写。

上面是以wait()+notify()的方式举了一个可重入锁的例子,下面以自旋锁举例:

自旋锁的可重入与不可重入

那么何为自旋锁呢?即某个线程在获取锁的过程中,如果发现锁已被其它线程占用,那么当前线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取锁才会退出当前循环。获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。下面首先设计一个不可重入的自旋锁:

package concurrent;

import java.util.concurrent.atomic.AtomicReference;

/**
 * 基于自旋锁
 * Created by xujia on 2019/5/29
 */
public class SpinLock {

    /**
     * 使用原子引用来存放线程
     */
    private AtomicReference<Thread> owner = new AtomicReference<>();

    public void lock() {
        Thread curThread = Thread.currentThread();
        // 基于CAS算法,如果null == owner,则将owner设为当前线程即默认取得锁,否则无限循环不断的判断是否能拿锁
        while (!owner.compareAndSet(null, curThread)) {
            // do nothing
        }
    }

    public void unlock() {
        Thread curThread = Thread.currentThread();
        owner.compareAndSet(curThread, null);
    }
}

将上述People类中的Lock类改为SpinLock,测试一下很容易发现这是不可重入的,下面改进一下

package concurrent;

import java.util.concurrent.atomic.AtomicReference;

/**
 * 基于自旋锁
 * Created by xujia on 2019/5/29
 */
public class SpinLock {

    /**
     * 使用原子引用来存放线程
     */
    private AtomicReference<Thread> owner = new AtomicReference<>();
    /**
     * 存放重入次数即计数器
     */
    private int count;

    public void lock() {
        Thread curThread = Thread.currentThread();
        // 与Lock类一样判断是否是当前线程,如果是则直接退出
        if (curThread == owner.get()) {
            count ++;
            return;
        }
        // 基于CAS算法,如果null == owner,则将owner设为当前线程即默认取得锁,否则无限循环不断的判断是否能拿锁
        while (!owner.compareAndSet(null, curThread)) {
            // do nothing
        }
    }

    public void unlock() {
        Thread curThread = Thread.currentThread();
        if (curThread == owner.get()) {
            count --;
            if (count == 0) {
                owner.compareAndSet(curThread, null);
            }
        }
    }
}

到这里就大功告成啦,可重入的自旋锁改造完成,再次运行保准通过。

 

Atomic原子类

楼主比较菜,目前为止实际开发中其实只用过AtomicInteger,但是也看了很多其余原子类的源码,其保持并发环境下线程安全的核心思路就是利用volatile的可见性以及CAS的原子性:

可见性保证:某个线程修改了volatile修饰的变量值,volatile保证能将该值立即同步更新到主内存中,以及每次读取都是从内存中进行读取,普通变量中间还有一层cpu缓存用于读写

原子性保证:主要利用Unsafe类的CAS操作方法,该类并不是提供给用户程序调用,因为它限制了只有启动类加载器(Bootstrap ClassLoader)加载的class才能访问它。CAS操作则在下面会简单介绍下,先贴个AtomicInteger的部分源码瞅一眼:

    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        // 有没有很熟悉?自旋实现,上面手动实现一个可重入的锁时也运用了该写法
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

该类中唯一一个比较重要的点就是valueOffset,也就是当前值在内存中的偏移量,因此CAS在进行比较交换时需要获得对应值在内存中的位置,当然对于给定的一个值其偏移量是永恒不变的

 

CAS比较交换

CAS即CompareAndSwap比较交换,是一种最常用的无锁算法,也是两类锁思想中乐观锁的典型代表。所谓乐观锁即总是假设当前线程在操作数据时不会有其余线程修改过该值,如果不巧发生了冲突,那么则不断重试直到操作成功。相比较Synchronized等悲观锁,CAS算法更轻量效率也更高,避免了因争抢锁而导致的线程间频繁调度产生的开销,同时也避免了死锁的产生。

在CAS算法中,总共有三个参数,即

  • V:需要读写的内存值
  • E:进行比较的期望值
  • N:拟写入的新值

当前仅当V==E时,才会将V的值更新为N值,下面举个小例子来简单说明

    public int add(int b) {
        a = a + b;
        return a;
    }

上面这段代码在执行时大致可分为三个步骤:

  1. 读取a的值
  2. 将a和b相加
  3. 将相加值赋给a

显然该段代码在多线程环境下很容易产生线程安全问题,当两个线程同时读取到a,并同时对a进行+1操作,最后a却只进行了一次+1操作,该问题出现的主要原因便是整个操作(读取、交换)并不是原子性操作,如果在将相加值赋给a的时候能多加一层判断,比如此时的a与最开始的a是否相等,如果相等则将相加值赋给a,代码如下:

    public void add(int b) {
        int t = a;
        int c = a + b;
        compareAndSet(a, t, c);
    }

    private void compareAndSet(int v, int e, int n) {
        if (v == e) {
            v = n;
        }
    }

这里的v就是CAS算法中的需要读写的当前内存值,e则是我们期望的值,也就是说在进行整个操作之前的a的值,防止在中途被其余线程进行修改过,n则是最终需要赋予的新值,当然这个比较与交换的操作仍然不是原子性的,那么加个锁就搞定啦,CAS便是在底层汇编代码进行加锁,由于其是硬件级别的,因此效率仍较高。

当CAS产生冲突时便会原地自旋,无限比较与交换,这也是CAS的一大弊端,长时间的自旋会给cpu带来比较大的开销。CAS的另一个问题便是ABA问题,CAS算法在比较时会判断某个值是否发生变化,那么当一个值从A到B再到A,那么此时CAS会发现它并没有改变,从逻辑上来说已经是错误了,常见的思路可使用版本号机制来解决,即每一次对变量的操作都加上版本号,那么ABA会变成1A->2B->3A,可以进行显示的区分了。

 

CountDownLatch

在多线程开发中,肯定有遇到过这样的一个场景:启动线程池并发执行任务时,主线程往往需要等待其余线程全部执行完毕之后才能继续执行之后的逻辑,比如启动多线程获取无关联数据,在最后封装所有数据时需等待所有线程获取完毕。或者说多线程导出实现,这也是我遇到的一个实际业务场景,这些场景下使用CountDownLatch能完美的解决 ~

简介

这是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。通俗点来说,它相当于一个计数器,初始化时需要赋线程数量即给定一个初始技术,每个线程在执行完操作之后技术器-1,只要计数器的值大于0,主线程则一直阻塞,直到等于0。

使用案例

该类提供的方法特别少,使用起来极其简单,一般就三个步骤,步骤见代码:

package concurrent;

import org.junit.Test;

import java.util.concurrent.CountDownLatch;

/**
 * Created by xujia on 2019/5/29
 */
public class CounterTest {

    private CountDownLatch countDownLatch;

    @Test
    public void test() {
        // 1、初始化计数器并设置等待线程数
        countDownLatch = new CountDownLatch(2);
        new ThreadTest().start();
        new ThreadTest().start();
        try {
            System.out.println("等待2个子线程执行完毕");
            // 3、阻塞等待,直到计数器为0
            countDownLatch.await();
            // 下面的业务逻辑需要等到上面多线程全部执行完毕之后才能执行
            System.out.println("2个子线程执行完毕");
            System.out.println("执行主线程");
        } catch (Exception e) {

        }
    }

    class ThreadTest extends Thread{
        @Override
        public void run() {
            try {
                System.out.println("线程" + Thread.currentThread().getName() + "正在执行");
                Thread.sleep(1500);
                System.out.println("线程" + Thread.currentThread().getName() + "执行完毕");
            } catch (Exception e) {

            } finally {
                // 2、每完成一个线程,需调用该方法,计数器减1
                countDownLatch.countDown();
            }
        }
    }
}

输出结果如下图,可以明显看到主线程被阻塞,只有当其余线程全部执行完毕后主线程才会继续执行

线程Thread-0正在执行
等待2个子线程执行完毕
线程Thread-1正在执行
线程Thread-0执行完毕
线程Thread-1执行完毕
2个子线程执行完毕
执行主线程

Process finished with exit code 0

这里需要注意的是在多线程操作时,必须要显示调用countDown(),否则await()判定时计数器一直大于0造成死锁,因此实际开发中比较推荐使用await(long timeout, TimeUnit unit),至少不会死锁。

源码分析

翻看源码,CountDownLatch类本身是没有继承或实现任何一个接口和类,其内部实现依赖于AbstractQueuedSynchronizer即AQS架构,AQS采用队列来实现线程之间的等待,这里不详细介绍AQS,只取与本计数器相关的方法作展开

1、首先我们来看构造方法,由上文可知,在初始化时需传入线程数量,然后用来初始化计数器初始计数

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        // Sync即继承了AQS的内部类,用于初始化计数器state,该变量使用volatile参数修饰实现线程间的可见性
        this.sync = new Sync(count);
    }


2、下面来看下countDown()方法:

    public void countDown() {
        // 调用AQS的方法
        sync.releaseShared(1);
    }

    public final boolean releaseShared(int arg) {
        // tryReleaseShared首先尝试释放锁
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }

    // 该方法在CountDownLatch的Sync中被重写
    protected boolean tryReleaseShared(int releases) {
            for (;;) {
                // 获取当前计数
                int c = getState();
                // 非空校验,如果计数为0直接返回
                if (c == 0)
                    return false;
                // 执行计数器减1操作
                int nextc = c-1;
                // CAS算法保证线程安全,若c与重新获取的state相等则将state修改为nextc,同时返回true
                if (compareAndSetState(c, nextc))
                    // 若此时计数器已经为0,则返回true,释放队列中的等待线程
                    return nextc == 0;
            }
        }

    private void doReleaseShared() {
        for (;;) {
            Node h = head;
            // 队列非空,表示有线程已经执行过await,被阻塞了
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                // 头结点如果为SIGNAL,则唤醒头结点下个节点上关联的线程,并出队
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            // 无阻塞情况直接返回
            if (h == head)                   // loop if head changed
                break;
        }
    }

总结下整体逻辑:

  1. 尝试释放锁tryReleaseShared,实现计数-1
  2. 若计数已经小于0,则直接返回false
  3. 否则执行计数(AQS的state)减一
  4. 若减完之后,state==0,表示没有线程占用锁,即释放成功,然后就需要唤醒被阻塞的线程了
  5. 释放并唤醒阻塞线程 doReleaseShared
  6. 如果队列为空,即表示没有线程被阻塞(也就是说没有线程调用了 CountDownLatch#wait()方法),直接退出
  7. 头结点如果为SIGNAL, 则依次唤醒头结点下个节点上关联的线程,并出队

3、接下来看下await方法:

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public final void acquireSharedInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        // 尝试获取锁,如果返回-1则进入队列阻塞
        if (tryAcquireShared(arg) < 0)
            doAcquireSharedInterruptibly(arg);
    }

    // Sync同样重写了该方法,如果计数器等于则直接返回1,否则为-1
    protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

    private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
        // 入队
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            // 无限循环获取锁
            for (;;) {
                // 获取前驱节点
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        // 获取锁成功,设置队列头为node节点
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

总结下整体逻辑

  1. 判断state计数是否为0,不是,则直接放过执行后面的代码
  2. 大于0,则表示需要阻塞等待计数为0
  3. 当前线程封装Node对象,进入阻塞队列
  4. 然后就是循环尝试获取锁,直到成功(即state为0)后出队,继续执行线程后续代码

写在最后,CountDownLatch实现的是共享锁,即允许同一时刻可以有多个线程获取锁,由await方法决定。

 

Fork/Join

这是jdk1.7之后推出一个并行框架,由名字也能得出该框架整体分为两步骤,fork拆分和join合并,通俗点来说即先将一个大任务递归分解成足够处理的小任务,然后合并各子任务的执行结果并返回。

                                          

框架简介

该框架由三个核心类组成:

ForkJoinPool:执行任务的线程池,其底层还是实现的ExecutorService接口,与我们平常创建线程池所用方法类似,只是多了扩展。

ForkJoinWorkerThread:执行任务的工作线程,这里的线程即线程池中的线程,每个线程均维护着一个内部队列,用于存放内部任务,继承于Thread。

ForkJoinTask:即我们所说的任务抽象类,由于其抽象方法较多,实际使用中我们一般继承其两个子类,分别为RecursiveTask(子任务带返回结果)和RecursiveAction(子任务不带返回结果),可根据具体需求继承不同的子类。

上面已经简单讲解了其组成结构,单单看这个结构好像我们用平常的线程池也能完成呀,这个框架的意义是什么呢?Fork/Join的核心便是其采用了”工作窃取(work-stealing)“算法,其算法通俗点来说:当遇到一个比较庞大的任务时,我们需要不断的分解成若干个互不依赖的子任务,为了减少线程间的竞争,我们需要把这些任务分别放到不同的队列中,每个队列分别由一个线程进行执行,也就是说线程与队列是1:1的关系,但是当某个线程执行完其队列中的所有任务后,与其让它苦等,不如让它去帮其他队列中的线程执行任务也就是窃取,但是为了减少窃取线程与被窃取线程之间取任务的竞争,队列被设计成双端队列即被窃取线程永远从队列头部拿任务,窃取线程永远从队列尾部拿任务。

因此该算法的核心优势就在于可以避免某个线程在完成任务后被阻塞等待,而是会扫描所有队列窃取任务,直到所有队列都为空才会被挂起,简易的窃取流程图如下:

                                           

使用案例

为了能将Fork/Join的优势对比出来,先以普通线程池的方式来完成拆分与合并的操作,再用Fork/Join框架来执行一波以对比优势点

选取的案例为计算0~1000的总和

package concurrent;

import org.junit.Test;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * 普通线程池测试
 * Created by xujia on 2019/5/30
 */
public class ThreadPool {

    // 固定大小的线程池,大小为当前机子cpu数量的两倍或者是cpu数+1,根据线程所执行任务的不同而不同
    private ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() + 1);

    @Test
    public void test() {
        CountTask countTask = new CountTask(1, 1000);
        // 提交主任务并输出结果
        Future<Integer> result = executorService.submit(countTask);
        try {
            System.out.println("总数为:" + result.get());
        } catch (Exception e) {

        }
    }

    class CountTask implements Callable<Integer> {

        /**
         * 开始计算点
         */
        private int start;
        /**
         * 结束计算点
         */
        private int end;

        /**
         * 每个任务计算的最大阈值
         */
        private static final int COUNT_LENGTH = 50;

        public CountTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        public Integer call() throws Exception {
            int sum = 0;
            if (end -start <= COUNT_LENGTH) {
                // 无需拆分
                for (int i = start; i <= end; i++) {
                    sum += i;
                }
            } else {
                System.out.println("正在进行拆分任务的线程为:" + Thread.currentThread().getName());
                // 拆分成左右两个子任务
                int mid = (end + start) / 2;
                CountTask left = new CountTask(start, mid);
                CountTask right = new CountTask(mid + 1, end);
                // 提交到线程池中允许
                Future<Integer> leftResult = executorService.submit(left);
                Future<Integer> rightResult = executorService.submit(right);
                // 合并结果
                sum = leftResult.get() + rightResult.get();
            }
            return sum;
        }
    }
}

运行结果为:

可以看到由于线程池中线程不足导致所有线程已经被阻塞,导致无法继续执行任务,下面看下Fork/Join框架的效果:

package concurrent;

import org.junit.Test;

import java.util.concurrent.*;

/**
 * fork/join 测试
 * Created by xujia on 2019/5/30
 */
public class ForkJoinTest  {

    @Test
    public void test() {
        long start = System.currentTimeMillis();
        CountTask countTask = new CountTask(1, 1000);
        ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
        int sum = forkJoinPool.invoke(countTask);
        System.out.println("总数为:" + sum + ",耗费时间为:" + (System.currentTimeMillis() - start) + "ms");

    }

    class CountTask extends RecursiveTask<Integer> {
        /**
         * 开始计算点
         */
        private int start;
        /**
         * 结束计算点
         */
        private int end;

        /**
         * 每个任务计算的最大阈值
         */
        private static final int COUNT_LENGTH = 50;

        public CountTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            // 存放结果
            int sum = 0;
            // 第一步:根据业务需求参数递归拆分任务
            if ((end - start) <= COUNT_LENGTH) {
                // 无需拆分,直接计算
                for (int i = start; i <= end; i++) {
                    sum += i;
                }
                System.out.println("当前线程正在执行:" + Thread.currentThread().getName() + ",start :" + start + ",end :" + end + ",sum:" + sum);
            } else {
                // 需要拆分任务,拆成左右两个
                int mid = (end + start) / 2;
                CountTask left = new CountTask(start, mid);
                CountTask right = new CountTask(mid + 1, end);
                // 最好调用invokeAll方法来代替每个子任务的fork方法,fork方法存在先后顺序,而invokeAll同时干活不会浪费线程,可以更好的利用线程池,降低执行时间
                invokeAll(left, right);
                // 第二步:整合每个任务的结果
                int leftResult = left.join();
                int rightResult = right.join();
                sum = leftResult + rightResult;
            }
            return sum;
        }
    }

}

运行结果为:

由于被切分的任务太多导致并不能一页全部展示,但是我们可以看到该任务已经被成功执行,为了更好的分析结果,我暂时把阈值调为100并且计算1~500的总和,结果如下:

1到500的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-1
1到250的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-1
1到125的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-1
251到500的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-2
376到500的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-4
当前线程正在执行:ForkJoinPool.commonPool-worker-1,start :1,end :63,sum:2016
当前线程正在执行:ForkJoinPool.commonPool-worker-4,start :376,end :438,sum:25641
当前线程正在执行:ForkJoinPool.commonPool-worker-1,start :64,end :125,sum:5859
126到250的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-3
当前线程正在执行:ForkJoinPool.commonPool-worker-4,start :439,end :500,sum:29109
当前线程正在执行:ForkJoinPool.commonPool-worker-5,start :189,end :250,sum:13609
251到375的和的任务正在被拆分:拆分的线程为:ForkJoinPool.commonPool-worker-2
当前线程正在执行:ForkJoinPool.commonPool-worker-3,start :126,end :188,sum:9891
当前线程正在执行:ForkJoinPool.commonPool-worker-7,start :314,end :375,sum:21359
当前线程正在执行:ForkJoinPool.commonPool-worker-2,start :251,end :313,sum:17766
总数为:125250,耗费时间为:4ms

Process finished with exit code 0

从第一行可以发现线程1将1到500主任务拆分成两个子任务即1到250的子任务和251到500的子任务,线程1将这两个任务放入其内部队列,作为内部任务,每个线程因拆分而产生的任务都会放入各自的内部队列中,这时线程1又执行了1到250的任务,而251到500的任务被线程2所窃取,接着线程1和线程2分别拆分,总之每个线程并不会因完成了任务而空闲,它总有事干,因此Fork/Join提供了很好的并发性能。

写在最后,上述循环计算总数的案例经过测试在循环次数过大的情况下使用Fork/Join框架并不能有效的节省时间,因为会过多的创建任务耗费内存,因此Fork/Join框架使用的场景推荐为:循环次数小但循环体中代码体耗时长的情况。

上文只是很简单的介绍了Fork/Join,具体内部执行原理以及源码部分日后空闲了有精力了再细细研究

 

ConcurrentHashMap

基于jdk1.8的源码进行分析,1.8之前其主要采用分段锁的思想,即Segment+ReentrantLock的组合,而到了1.8就变成了Synchronized+CAS的组合,关于其为什么会修改锁组合,本人的理解是一方面使用内置锁随着编译器的优化,内置锁会一并提高锁的性能,另一方面从ConcurrentHashMap源码层面看,Synchronized锁的其实只是一个Node节点对象,锁的粒度非常小,因此其出现并发争抢的可能性已经很低了,同时CAS无锁算法在性能上比ReentrantLock还是高的。

本类其实大部分操作与HashMap都是类似的,因此这边就稍微看下ConcurrentHashMap的put方法,其get方法也是没有进行加锁操作的,通过volatile修饰Node节点中的val来保证读取时不会读到脏数据。这里要尤其注意map.put(KEY, map.get(KEY) + 1)这种写法,这是会造成线程不安全的一个例子,ConcurrentHashMap的线程安全指的是其每个方法单独调用都是线程安全的,但是代码总体的互斥性并不受控制。具体我举得例子在多线程环境下为啥线程不安全就留给各位自己琢磨了。

首先我们先来看下一个比较初始化及扩容比较重要的一个参数:sizeCtl,这是一个控制标识符,不同的值表示不同的意义

  • 负数代表正在进行初始化或扩容操作 ,其中-1代表正在初始化 ,-N 表示有N-1个线程正在进行扩容操作
  • 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,类似于扩容阈值。它的值始终是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。实际容量>=sizeCtl,则扩容。

然后来看下在ConcurrentHashMap中被用的最多的三个原子操作,也是实现线程安全的一大武器

// 获取tab数组的第i个node
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// 利用CAS算法设置i位置上的node节点。在CAS中,会比较内存中的值与你指定的这个值是否相等,如果相等才接受
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 利用volatile方法设置第i个节点的值,这个操作一定是成功的。
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}

最后来看下put方法:

 final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());//计算hash值,两次hash操作
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {//类似于while(true),死循环,直到插入成功 
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0)//检查是否初始化了,如果没有,则初始化
                tab = initTable();
                /*
                    i=(n-1)&hash 等价于i=hash%n(前提是n为2的幂次方).即取出table中位置的节点用f表示。
                    有如下两种情况:
                    1、如果table[i]==null(即该位置的节点为空,没有发生碰撞),则利用CAS操作直接存储在该位置,
                        如果CAS操作成功则退出死循环。
                    2、如果table[i]!=null(即该位置已经有其它节点,发生碰撞)
                */
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)//检查table[i]的节点的hash是否等于MOVED,如果等于,则检测到正在扩容,则帮助其扩容
                tab = helpTransfer(tab, f);//帮助其扩容
            else {//运行到这里,说明table[i]的节点的hash值不等于MOVED。
                V oldVal = null;
                synchronized (f) {//锁定,(hash值相同的链表的头节点)
                    if (tabAt(tab, i) == f) {//避免多线程,需要重新检查
                        if (fh >= 0) {//链表节点
                            binCount = 1;
                            /*
                            下面的代码就是先查找链表中是否出现了此key,如果出现,则更新value,并跳出循环,
                            否则将节点加入到里阿尼报末尾并跳出循环
                            */
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)//仅putIfAbsent()方法中onlyIfAbsent为true
                                        e.val = value;//putIfAbsent()包含key则返回get,否则put并返回  
                                    break;
                                }
                                Node<K,V> pred = e;
                                if ((e = e.next) == null) {//插入到链表末尾并跳出循环
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        else if (f instanceof TreeBin) { //树节点,
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {//插入到树中
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                //插入成功后,如果插入的是链表节点,则要判断下该桶位是否要转化为树
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)//实则是>8,执行else,说明该桶位本就有Node
                        treeifyBin(tab, i);//若length<64,直接tryPresize,两倍table.length;不转树 
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

附上一张put方法简要的流程图

就暂时先到这

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

清茶_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值