JUC学习笔记

volatile

file

  • 关于上述程序的说明:
    main方法中开启了两个线程,一个共享数据flag。myThread线程在100毫秒后将flag修改为true,主线程循环读取flag,如果为true则打印一句话。
    但是执行结果发现,即使myThread将flag修改为true,主线程仍然没有打印任何值。
  • 解释:
    每个线程都会有自己的工作线程,且他们共享一块主内存。myThread线程从主内存读取数据后,在工作内存中修改数据并同步进主内存,但由于 while(true) 执行效率非常快,main线程从主内存获取数据之后根本来不及重新从主内存中读取数据,所以一直读取的是旧数据。解决方案:死循环中加入延时/加入同步锁/使用volatile修饰变量

volatile

使用内存栅栏技术(防止指令重排序),使得线程每次获取数据都是从主存中获取
相较于synchronized是一种更轻量级的同步策略

缺点

  1. 不具备互斥性,可以多线程同时读写
  2. 不保证操作原子性

原子变量

使用volatile能保证可见性,但是无法保证对变量修改的原子性(例如 i++ 问题),此时可以使用 java.util.concurrent.atomic 包下提供的常用原子变量

  • 这些原子变量内部都使用了volatile保证可见先
  • 对这些变量的修改都使用了 CAS 保证数据的原子性
private AtomicInteger num = new AtomicInteger();
num.getAndIncreasment(); // 自增

CAS

包含了三个操作数 内存值V,预估值A,更新值B。当且仅当 V== A 时,才会将B赋给V。这三步是同步的
会出现ABA问题

synchronized

synchronized 默认使用当前对象作为锁,如果是静态方法则使用当前class对象作为锁。
在某时刻内,只能有一个线程能够获取锁

ConcurrentHashMap/CountDownLatch

线程安全的Hash表

  • HashMap 和 HashTable
    HashMap线程不安全
    HashTable线程安全,但效率低,每个方法都使用同步锁,并且复合操作(例:如果存在即修改)也是线程不安全

ConcurrentHashMap 锁分段机制

并发级别(concurrentLevel)

默认分为16段(segment),每个段中又有一个16单位长度的数组,数组的每个元素下又挂着一个链表
这样的话每个段都有一个独立的锁,所以可以多线程同时访问多个不同的段
jdk1.8之后每个段也修改为使用cas操作

  • 此包下还提供了很多其他的集合,例如 CopyOnWriteArrayList,该类也是一个集合,用法同 List,但是每次向其中添加元素时,它都会重新复制一个新的List并指向原集合。所以对集合有很多的添加操作时效率较低,开销大。通常可以用于多线程使用迭代器遍历,并在遍历过程中添加元素(注:普通的list即使在迭代器遍历过程中添加元素也会报错)

CountDownLatch 闭锁

在进行某些运算时,只有其他所有线程的运算全部完成,当前运算才继续执行


// 这里的 2 代表这个闭锁可以监控2个线程是否结束,内部维护一个整型变量=2,每次子线程结束就-1,直到为0
CountDownLatch latch = new CountDownLatch(2)
new Thread(new MyThread(latch)).start();
new Thread(new MyThread(latch)).start();
latch.wait(); // 会一直等到闭锁值为0

class MyThread implements Runable{
    private CountDownLatch latch;
    public void run() {
        // do something
        this.latch.countDown(); // 子线程结束后,需手动将闭锁减一
    }
}

Callable接口

创建线程的方式有四种

  1. 继承Thread类
  2. 实现Runable接口
  3. 实现Callable接口
  4. 使用线程池
  • Callable接口和实现Runable接口区别
    1. Callable接口使用call()方法,Runable使用run()
    2. Callable接口带泛型,call方法有返回值
    3. call方法会抛异常
    4. 执行Callable需要FutureTask实现类的支持,用于接收运算结果,FutureTask是Future接口的实现类

file

同步锁Lock

  • 解决多线程安全问题的方式:
    1. 同步代码块 synchronized
    2. 同步方法 synchronized
    3. 同步锁 Lock(jdk1.5之后)

注:Lock是一种显式锁,需要手动通过 lock() 和 unlock() 方法进行加锁和放锁

MyThread implements Runable{
    private Integer source = new Integer();  // 多线程共享的变量
    private Lock lock = new ReentrantLock();  // 多线程共享的锁

    public void run() {
        lock.lock();  // 手动加锁
        // do something
        lock.unlock();  // 手动放锁
    }
}

等待唤醒机制

即Object的wait和notify方法实现的效果

注:wait和notify会释放锁

  • 生产者消费者模式会产生的问题
    生产者生产速度过快,或者消费者消费速度过慢都可能导致数据丢失。或者生产者生产速度过慢,消费者一直尝试获取数据而浪费资源
    解决方案:生产者没有生产数据时使用wait等待,此时因为消费者使用同一把锁,故也会等待,就不会产生空轮询的问题

  • 使用wait和notify的问题

会产生虚假唤醒的问题:假如有两个消费者线程处于wait状态(即资源数为0),此时一个生产者将资源数+1并尝试唤醒消费者线程,则这两个消费者都会被唤醒并连续将资源数-1,此时资源数就成了-1,发生异常
解决:为了解决虚假唤醒问题,wait() 和 notify()应该总是放在循环语句中使用(即使被虚假唤醒之后也会再次进行判断)
或者使用Lock中Condition类的方法

Condition condition = lock.getCondition(); // 需要从Lock实例中获取
condition.await() // 等同于obj.wait
condition.signalAll // 等同于 obj.notifyAll()

读写锁

多线程同时读写需要互斥操作,多线程读不需要互斥


private int resource = 0;  //  共享资源
private ReadWriteLock lock = new ReentrantReadWriteLock(); // 需多线程共享
// 读
public void get() {
    lock.readLock().lock();
    print(resource);
    lock.readLock().unloock(); // 释放锁,通常放在funally块中
}
// 写
public void set(int num){
    lock.writeLock().lock();
    this.resource = num;
    lock.writeLock().unlock();
}

线程池

频繁创建销毁线程耗费性能
线程池中维护了一个线程队列,队列中保存着所有等待状态的线程。

线程的体系结构

file

  • 工具类
    ExecutorService newFixedThreadPool(): 创建固定大小的线程池
    ExecutorService newCachedThreadPool(): 缓存线程池,线程池数量不固定,可以根据需求自动更改数量
    ExecutorService newSingleThreadExecutor(): 创建单个线程池,线程池只有一个线程
    ScheduledExecutorService newScheduledThreadPool(): 创建固定大小的线程,可以延迟或定时执行任务

使用

public static void main(Stringp[] args) {
    // 创建含有5个线程的线程池
    ExecutorService pool = Executors.newFixedThreadPool(5);
    // 向线程池提交任务
    pool.submit(new Runable() {
        // do something.
    });
    // 关闭线程池
    pool.shutdown() // 等待所有任务结束之后才会自动关闭
    pool.shutdownNow() //立即关闭
}

Fork/Join框架

在必要的情况下,将一个大任务拆分成若干个小任务,再将一个个小任务运算结果进行join汇总(类比map/reduce,其实质是递归调用)

file

AQS

JUC的基础就是AQS,AQS基础时可重入锁和LockSupport

LockSupport类

可以理解为:线程等待唤醒机制的加强版(即:使用park/unpark代替/wait/notify)
限制:wait/notify必须在同步代码块中执行
await/signal必须在Lock.lock()和Lock.unlock()中执行

  • 使用LockSupport
    file

  • 基本原理
    LockSupport中所有的方法都是静态方法
    unpark(Thread t)方法会为传入的线程发放一张许可证,在该线程内调用的park()方法时会检查是否存在许可证。故可以在park()方法调用前就调用unpark()方法
    一个线程只能有一个许可证,故多次调用unpark方法只会对一个park方法生效,其他的park方法仍然会阻塞

AQS(抽象同步队列)

多线程获取同一个锁,必然会有一些线程需要等待,AQS使用了一个双向队列(CHL队列)来维护每个锁所需要等待的线程,并使用一个volatile的int类型的变量维护锁状态。例如,当该状态为0时,则表示没有线程占用锁,等于1则说明当前锁需要等待。该状态通过CAS、LockSupport.park()的方式进行维护

  • 所有被维护的等待线程都会封装称为一个Node类型的内部类

  • Node类中也维护了一个名为waitState的int类型的变量和被维护的线程及前后Node的指针

  • waitState表示当前维护线程的状态,例如0表示正常等待,1表示放弃获取锁

源码解读(详细过程)

任何一个Lock的实现类都是在内部封装了一个AQS的实现类,调用Lock的方法本质上都是调用该AQS实现类的方法
例如:当调用Lock lock = new RenntrantLock()时,其本质上是 创建了一个NonfairSync(非公平锁)对象,该对象继承自AQS,当调用 lock.await()时,本质是调用该AQS对象的wait方法。所以在此需详细解读AQS的锁方法
注:

  1. 公平锁:先到先获取锁,如果这个锁的等待队列中已经有线程在等待,则新来的线程将进入该队列等待。非公平锁:不管是否有等待队列,如果可以获取锁,则立刻占有锁对象
  2. 公平锁和非公平锁底层代码基本一致,唯一的区别是公平锁会判断等待队列中是否存在有效节点
详细过程模拟

假设有A、B、C三个线程使用非公平锁同时抢占锁L,则会发生如下情况

  1. 初始状态时,AQS中的state=0,即锁L处于空闲状态
  2. A尝试获取锁,使用CAS将state设为1,同时,将AQS中的变量currentThread设置为A(即当前持有锁的线程,初始为null)
  3. 此时B尝试获取锁,发现当前stat为1,则使用CAS尝试将自己加入等待队列:创建一个空的Node,将其加入到队列头节点,并设置其waitState值为-1(该空节点实际上是A线程的代表,表示正在运行的节点)。再创建一个封装了B线程的Node,将其加入到头节点之后,完成入队操作
  4. B加入到队列之后,会使用一个死循环尝试获取锁(此时线程阻塞在lock()方法上)。但是在第一次循环时,如果没能获取到锁,即调用LockSupport.park()方法阻塞线程,直到A线程调用unlock(),本质上调用AQS的release()方法,该方法会去队列寻找下一个排队的线程B并设置state=0,然后调用unpark(B)方法后循环继续,判断state的值,设置state=1,直到获取到锁,当B获取锁后,将B设置为等待队列的头指针,并将其waitState设置为-1,Thread设置为null(一个新的头节点)。将最开始创建的空的头节点设置为null,等待GC回收
  5. 如果B还在等待的情况下,C也需要获取锁,则C会直接加到等待队列尾部

Leave a Comment