多线程初阶总结

线程

  1. 线程的原理,进程和线程的原理
  2. 线程的使用Thread类的用法
  3. 线程安全问题
  4. 等待通知机制
  5. 多线程的代码案例

定时器

  1. 定义类描述任务
  2. 使用优先队列
  3. schedule
  4. 扫描线程,负责执行队列中的任务

wait/notify

线程唤醒

多线程进阶

常见的锁策略

对于synchronized已经可以覆盖大多数使用场景,此处的“锁策略”不是和 Java 强相关的,其他语

言,但凡涉及到并发编程,涉及到锁,都可以谈到这样的锁策略.

锁策略的特点

1.悲观锁 vs 乐观锁

不是针对某一种具体的锁,而是某个具体锁具有“悲观”特性或者“乐观”特性

  • 悲观:加锁的时候,预测接下来的锁竞争的情况非常激烈。就需要针对这样的激烈情况额外做一些工作。

例如:有一把锁有二十个线程尝试获取锁。每个线程加锁的频率都很高。一个线程加锁的时候,很

可能锁被另一个线程占用着

  • 乐观:加锁的时候,预测接下来的锁竞争的情况不激烈。就不需要做额外工作

例如:有一把锁假设只有两个线程尝试获取这个锁,每个线程加锁的频率都很低。一个线程加锁的

时候,大概率另一个线程没有和他竞争

对于无论悲观锁还是乐观锁,都是描述的是加锁时候遇到的场景

2.重量级锁 vs 轻量级锁
  • 重量级锁,当悲观的场景下,此时就要付出更多的代价 => 更低效
  • 轻量级锁,应对乐观的场景,此时付出的代价就会更小 => 更高效

对于无论重量级锁还是轻量级锁,都是遇到场景之后的解决方案

3.挂起等待锁 vs 自旋锁
  • 挂起等待锁 :重量级锁 的典型实现。

操作系统内核级别的。加锁的时候发现竞争,就会使该线程进入阻塞状态。后续就需要内核进行唤

醒了。

获取锁的周期更长,很难即使获取,但是这个过程就不必一直消耗cpu,把cpu省出来做别的事

                悲观锁 => 重量级锁 => 挂起等待锁

  • 自旋锁 :轻量级锁的典型实现。

应用程序级别的。加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式来进行等待。

乐观锁的场景本身遇到锁竞争的概率就很小,真的遇到竞争在短时间内就能拿到锁。获取锁的周期

更短,即使获取到锁,这个过程也会一直消耗cpu

                乐观锁 => 轻量级锁 => 自旋锁

补充:

synchronized是种比较特殊的锁,既是乐观又是悲观,是自适应的状态

JVM 内部, 会统计每个锁 竞争的激烈程度

  1. 如果竞争不激烈, 此时 synchronized 就会按照轻量级锁(自旋)
  2. 如果竞争激烈, 此时 synchronized 就会按照重量级锁(挂起等待)
4.互斥锁 vs 读写锁

互斥锁:像synchronized这类锁进行加锁解锁操作

读写锁:读方式加锁,写方式加锁,解锁

多个线程读取一个数据是本身就线程安全的
多个线程读取一个线程修改肯定会涉及到线程安全问题。

如果把读和写都加上普通的互斥锁,数据的读取方之间也会产生互斥锁,意味着锁冲突非常严重

  1. 读写锁确保读锁和读锁之间不是互斥的(不会产生阻塞)
  2. 写锁和读锁之间,才产生互斥
  3. 写锁和写锁之间,也有互斥。

保证线程安全的前提下,降低锁冲突的概率,提高效率。

5.可重入锁 vs 不可重入锁

可重入锁:一个线程 一把锁连续加锁多次 是否会死锁.

核心要点:

  1. 锁要记录当前是哪个线程拿到的这把锁.
  2. 使用计数器, 记录当前加锁了多少次, 在合适的时候进行解锁
6.公平锁 vs 非公平锁

公平锁: 遵守 "先来后到". B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.

非公平锁: 不遵守 "先来后到". B 和 C 都有可能获取到锁.

注意

操作系统的线程调度是随机的. 默认锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据

结构, 来记录线程们的先后顺序

总结

1.悲观和乐观

2.重量和轻量

3.挂起等待锁和自旋锁

4.互斥锁和读写锁

5.可重入锁和不可重入锁

6.公平锁和非公平锁

synchronized详细情况

锁升级

无锁 --> 偏向锁 --> 自旋锁 -                                                                                                                -> 重量级锁

偏向锁

 synchronized一上来不是真加锁,而是只是简单做一个标记,这个标记非常轻量相比于加锁解锁来

说效率高很多

如果没有其他线程来竞争这个锁, 最终当前线程执行到解锁代也就只是简单清除上述 

( 标记即可~~不涉及真加锁, 真解锁)

如果有其他线程来竞争, 就抢先一步, 在另一个线程拿到锁之前, 抢先拿到锁

(偏向锁 => 轻量级锁. 其他线程只能阻塞等待)

锁升级总结

无锁 --> 偏向锁 : 代码进入 synchronized 的 代码块
偏向锁 --> 轻量级锁 : 拿到偏向锁的线程运行过程中, 遇到了其他线程尝试竞争这个锁.
轻量级锁 --> 重量级锁 : JVM 发现, 当前竞争锁的情况非常激烈

当前 JVM 中, 只提供了 "锁升级" 不能 "锁降级"

锁消除


锁消除也是编译器优化的一种体现.
编译器会判定, 当前这个代码逻辑是否真的需要加锁. 如果确实不需要加锁, 但是写了 synchronized,

就会自动把 synchronized 给去掉.

锁组化

锁的粒度

加锁和解锁之间,包含的代码越多,就认为锁的粒度就越粗

如果包含的代码越少,就认为锁的粒度就越细(不是代码行数,实际执行的指令/时间)

一个代码中,反复针对细粒度的代码加锁,就可能被优化成更粗粒度的加锁。

在多次加锁的情况下,每次解锁之后重新加锁都会增加竞争,本来是执行多次加锁解锁可能会被优

化成一次加锁解锁

并不是锁越粗效率越高,视具体情况认定

CAS

CAS: 全称Compare and swap,字面意思:”比较并交换“

一个 CAS 涉及到以下操作

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

CAS

CAS是CPU的一条指令,这样的指令就给编写多线程代码/线程安全提供了很多便利

CAS本质是CPU的指令,操作系统把这个指令封装,提供一些api就可以在JVM中被调用

CAS的主要用途

  • 实现原子类

boolean, int, long 这些类型进行加,减操作(例如count++)是线程不安全的,就需要加锁来解决

问题,但是又认为,加锁效率比较低

于是就可以通过 CAS 来实现 count++,确保性能,同时保证线程安全

是原子类的目的就是为了避免加锁

原子类专有名词特指atomic这个类

synchronized与原子类的区别

synchronized保证的是一个修改的原子性

原子类本身具有原子性

原子类的使用

 private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

对于上面这样的一个代码,当不加锁时会出现线程不安全的情况,由上面其他办法来解决

 private static AtomicInteger count=new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();//count++
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();//count++
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

通过引入原子类发现线程安全的问题解决了,具体分析原子类操作中各处代码的含义

AtomicInteger

实例化一个对象,同时进行初始化

getAndIncrement()

类似于count++操作

incrementAndGet()

addAndGet()

类似于++count操作

  • 实现自旋锁

自旋锁的实现

加锁操作中,就需要判定,锁是否被人占用,如果未被人占用,就把当前线程的引用设置到 owner

中,如果已经被人占用,就等待。

如果发现锁已经被占用,CAS 不会执行交换,返回 false进入循环再进入下一次判定,由于循环体

是空着的。整个循环速度非常快(忙等)但是一旦其他线程释放了锁,此时该线程就能第一时间拿

到这里的锁,此时就是自旋

CAS的缺陷

ABA问题

使用 CAS 能够进行线程安全的编程, 核心就是 先比较 "相等", 内存和寄存器是否相等

 如果发现这里寄存器和内存的值一致, 就可以认为是没有线程穿插过来修改,但是实际上可能存

在一种情况, 另一个线程把内存从 A 修改成 B, 又从 B 修改回 A.

CAS 中的 ABA 问题, 其实大部分情况下即使出现了 ABA, 最终的程序一般也问题不大。只有一些

极端的场景, ABA 问题才回产生一些严重 bug

为了有效避免ABA问题,我们可以使用”版本号“的方法,每次修改版本号就+1

JUC(java.util.concurrent)的常见类

Callable 接口

Callable接口和Runnable接口并列关系

Callable接口返回值call(),泛型参数

Runnable接口返回值是void()类型

Thread本身不提供获取结果的方法需要凭FutureTask对象来拿到结果

Thread的构造方法没有提供版本传入Callable对象,但可以实现Runnable对象

public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result=0;
                for (int i = 0; i < 50; i++) {
                    result+=1;
                }
                return result;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);
        Thread t=new Thread(futureTask);
        t.start();
        System.out.println(futureTask.get());
    }

Thread就是线程和任务这个概念能剥离开,更不关心任务是啥样的任务(是否有返回值)

创建线程的写法

  1. 继承 Thread (定义单独的类/匿名内部类)
  2. 实现 Runnable (定义单独的类/匿名内部类)
  3. lambda
  4. 实现 Callable (定义单独的类/匿名内部类)
  5. 线程池, ThreadFactory

ReentrantLock

ReentrantLock是可重入互斥锁,ReentrantLock和synchronized是并列的关系,都是用来实现互斥

效果, 保证线程安全

synchronized和ReentrantLock之间的区别

  1. synchronized 是关键字(内部实现是 JVM 内部通过 C++ 实现的),ReentrantLock 标准库的类(Java)
  2. synchronized 通过代码块控制加锁解锁, ReentrantLock 需要 lock/unlock 方法需要注意 unlock 不被调用的问题.
  3. ReentrantLock除了提供 lock, unlock 之外, 还提供了一个方法, tryLock()
  4. ReentrantLock提供了公平锁的实现默认是非公平的,可以通过构造方法传入一个 true开启公平锁模式
  5. ReentrantLock搭配的等待通知机制是Condition类,相比wait/notify来说功能更强大一些,
tryLock()

tryLock()是判断加锁的情况,加锁成功返回true;加锁失败返回false,调用者判定返回值决定接下

来怎么做

tryLock()也可以设置超时时间,等待时间达到超时时间再返回true/false

 private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker=new ReentrantLock();
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                locker.lock();
                count++;
                locker.unlock();
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                locker.lock();
                count++;
                locker.unlock();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

如何选择使用哪个锁

锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便

 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等

 如果需要使用公平锁, 使用 ReentrantLock

信号量 Semaphore

信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器描述了某种可用资源的个数,能够协调

多个进程之间的资源分配,也能够协调多个线程之间的资源分配

使用资源信号量 +1 ,这个称为信号量的 P 操作(对应Java中的acquire())

返回资源信号量 -1 ,这个称为信号量的 V 操作(对应Java中的release())

public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore=new Semaphore(3);
        semaphore.acquire();
        System.out.println("p");
        semaphore.acquire();
        System.out.println("p");
        semaphore.acquire();
        System.out.println("p");
    }

计数器为0继续申请,就会阻塞等待

信号量的一个特殊情况: 初始值为 1 的信号量.,取值要么是 1 要么是 0 (二元信号量),此时等价于

"锁" (普通的信号量, 就相当于锁的更广泛的推广)

private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore=new Semaphore(1);
        Thread t1=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

CountDownLatch

同时等待 N 个任务执行结束

CountDownLatch的引出

使用多线程经常将一个大任务拆分成多个小任务,使用多线程执行这些子任务从而提高程序的效率

对于这些小任务怎么确定这些小任务都完成了?

  1. 构造方法指定参数描述拆成了多少个字任务
  2. 每个任务执行完毕之后都调用一次countDown方法
  3. 主线程调用await方法等待所有任务执行完毕,awiat就会返回否则一直阻塞
  public static void main(String[] args) throws InterruptedException {
         CountDownLatch latch=new CountDownLatch(10);
         ExecutorService service= Executors.newFixedThreadPool(4);
          for (int i = 0; i < 10; i++) {
                int finalI = i;
             service.submit(() -> {
                 System.out.println("子任务开始执行" + finalI);
                  try {
                     Thread.sleep(1000);
                 } catch (InterruptedException e) {
                     throw new RuntimeException(e);
                    }
                    System.out.println("子任务结束执行" + finalI);
                    latch.countDown();
                });
            }
        latch.await();
        System.out.println("任务执行完毕");
        service.shutdown();
    }

现在把整个任务拆成 10 个部分每个部分视为是一个 "子任务",可以把这 10 个子任务丢到线程池中让线程池执行,当然也可以安排 10 个独立的线程执行.
 

 这个方法阻塞等待所有的任务结束,此处的 a => all


CountDownLatch是原子类,很实用但是用途不是特别广泛只针对特定场景解决方案

多线程下使用ArrayList

1.自行枷锁 [推荐]

分析清楚要把哪些代码打包到一起成为一个“原子”操作

2.Collections synchronizedList(new ArrayList);

相当于套壳,返回的List的各种关键字都是带有synchronized,类似于Vector Hashtable StringBuffer

3.使用CopyOnWriteArrayList

不去加锁而是去进行写时拷贝,CopyOnWrite容器即写时复制的容器。

原理:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行

Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指

向新的容器。

要么完全读取未修改的数据,要么读取已经修改的数据,不会读到修改到一般的数据

缺陷

  1. 数组特别大的时候会非常低效
  2. 如果多个线程同时修改也容易出现问题

适用于特定场景的方案

多线程使用哈希表

HashMap 本身不是线程安全的.

在多线程环境下使用哈希表可以使用:

Hashtable (给各种public方法都加synchronized)

ConcurrentHashMap(效率更高,按照桶级别进行加锁而不是给整个哈希表加锁,有效降低锁冲突)

Hashtable

只是简单的把关键方法加上了 synchronized 关键字.

 缺陷

如果多线程访问同一个 Hashtable 就会直接造成锁冲突.

size 属性也是通过 synchronized 来控制同步是比较慢的.

一旦触发扩容就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝效率会非常低.

ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化

以下面这张图为例

对于这样一个哈希表,Hashtable是采用全局加一把锁的形式,此时任意两个线程访问任意的两个

不同元素都会产生锁竞争,如果修改的两个元素, 在不同链表上本身就不涉及线程安全问题 (修改

不同变量)

ConcurrentHashMap会针对不同的锁对象进行加锁不会产生锁竞争,针对每个哈希桶的链表的头

节点进行加锁

ConcurrentHashMap 核心优化点:

  1. 把锁整个表 => 锁桶
  2. 使用原子类针对size进行维护
  3. 针对哈希扩容的场景,化整为零,确保每个操作的加锁时间不要太长.每次操作一次, 加锁时间 1ms

更多推荐