JavaEE初阶第十四期:解锁多线程,从 “单车道” 到 “高速公路” 的编程升级(十二)

专栏:JavaEE初阶起飞计划

个人主页:手握风云

目录

一、JUC的常见类

[1.1. Callable接口](#1.1. Callable接口)

[1.2. ReentrantLock​](#1.2. ReentrantLock)

[1.3. 信号量Semaphore](#1.3. 信号量Semaphore)

[1.4. CountDownLatch](#1.4. CountDownLatch)

二、线程安全的集合类

[2.1. 多线程环境使用 ArrayList​](#2.1. 多线程环境使用 ArrayList)

[2.2. 多线程环境使用哈希表](#2.2. 多线程环境使用哈希表)


一、JUC的常见类

1.1. Callable接口

Callable是一个interface,类似于Runnable,把线程封装了⼀个"返回值",方便程序员借助多线程的方式计算结果。

下面一个场景:创建线程计算1+2+3+4+......+100。

第一种写法:通过Runnable的方案,需要借助成员变量sum,耦合性比较高。

java 复制代码
public class Demo1 {
    private static int sum = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            int ret = 0;
            for (int i = 1; i <= 100; i++) {
                ret += i;
            }
            sum = ret;
        });

        t1.start();
        t1.join();

        System.out.println(sum);
    }
}

第二种写法:使用Callable版本。

java 复制代码
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo2 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<>(){
            @Override
            public Integer call() throws Exception {
                int ret = 0;
                for (int i = 1; i <= 100; i++) {
                    ret += i;
                }
                return ret;
            }
        };

        FutureTask<Integer> task = new FutureTask<Integer>(callable);
        Thread t = new Thread(task);

        t.start();
        System.out.println(task.get());
    }
}

Callable带有泛型参数,可以作为返回值计算结果。我们还需要重写里面的call()方法来计算结果,再把callable利用FutureTask包装一下,然后创建线程,将task传入线程的构造方法中。在主线程中调用task.get(),能够阻塞等待新线程计算完毕,并获取到FutureTask中的结果。

对于FutureTask的理解,FutureTask是 Java 并发编程中异步任务与结果获取的桥梁,通过封装状态管理、线程同步和异常处理,显著简化了异步编程模型。我们可以想象去吃麻辣烫,当餐点好后,后厨就开始做了,同时前台会给你一张 "小票",这个小票就是FutureTask,后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。

1.2. ReentrantLock​

ReentrantLock也是可重入锁,"Reentrant" 这个单词的原意就是 "可重入"​。与synchronized定位类似,都是用来实现互斥效果,保证线程安全。

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

public class Demo3 {
    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 < 50_000; i++) {
                locker.lock();
                count++;
                locker.unlock();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50_000; i++) {
                locker.lock();
                count++;
                locker.unlock();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(count);
    }
}

ReentrantLock和synchronized 的区别:

  • synchronized 是一个关键字,是 JVM 内部实现的。ReentrantLock是标准库的一个类, 在 JVM外实现的。
  • synchronized使用时不需要手动释放锁,ReentrantLock 使用时需要手动释放,使用起来更灵活,但是也容易遗漏unlock。
  • synchronized 在申请锁失败时,会死等,ReentrantLock可以通过trylock的方式等待一段时间就放弃。
  • synchronized是非公平锁,ReentrantLock默认是非公平锁。可以通过构造方法传入一个true 开启公平锁模式。
  • 更强大的唤醒机制,synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程。ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程。

1.3. 信号量Semaphore

信号量,用来表示 "可用资源的个数",本质上就是一个计数器。我们申请资源(P操作),就会使计数器-1;释放资源(V操作),就会使计数器+1。上述+1、-1的操作都是原子的。如果计数器为0,再去申请资源,就会造成阻塞。举个例子,我们开车寻找停车场时,开进去,电子牌上的空闲车位就会-1,开出去,电子牌上的空闲车位就会+1。如果没有空闲车位,就得停车等待或者寻找其他停车场。

java 复制代码
import java.util.concurrent.Semaphore;

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        // 初始许可数为3,也就是"可用资源"的个数
        Semaphore semaphore = new Semaphore(3);
        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.release();
    }
}

上面初始值设为3,当我们申请4次之后,就会产生阻塞。

java 复制代码
import java.util.concurrent.Semaphore;

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        // 初始许可数为3,也就是"可用资源"的个数
        Semaphore semaphore = new Semaphore(3);
        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.acquire();
        System.out.println("执行P操作");

        semaphore.release();
    }
}

信号量相当于锁概念的延伸。换句话说,锁也可以看作时初始值为1的特殊信号量。如果我们想要编写的多线程代码不允许使用锁,也可以使用信号量保证线程安全。

java 复制代码
import java.util.concurrent.Semaphore;

public class Demo4 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(1);

        // 创建一个线程t1,该线程执行的任务是:循环50_000次,每次执行时获取semaphore的许可,然后count加1,最后释放semaphore的许可
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50_000; i++) {
                try {
                    //  获取semaphore的许可
                    semaphore.acquire();
                    // count加1
                    count++;
                    // 释放semaphore的许可
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50_000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

1.4. CountDownLatch

CountDownLatch是Java 并发包中的同步辅助工具,用于协调多个线程的执行顺序,同时等待多个任务执行结束。比如在跑步⽐赛中,8个选⼿依次就位,哨声响才同时出发;所有选⼿都通过终点,才能公布成绩。

java 复制代码
import java.util.concurrent.CountDownLatch;

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(8);
        for (int i = 0; i < 8; i++) {
            int id = i;
            Thread t = new Thread(() -> {
                // 通过sleep模拟
                try {
                    Thread.sleep(1_000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程" + id + "执行完毕");
                // 计数器减一,相当于一个运动员到达终点
                latch.countDown();
            });
            t.start();
        }
        // 主线程通过await()方法等待所有线程结束
        latch.await();
        System.out.println("所有任务执行完毕");
    }
}

CountDownLatch通常用于一些特场景:在开发工作中,把一个大任务拆分成多个子任务,通过多线程并发执行,把所有任务完成之后才能进入下一阶段。

二、线程安全的集合类

2.1. 多线程环境使用 ArrayList​

  • 自己使用同步机制 (synchronized 或者 ReentrantLock)
  • Collections.synchronizedList(new ArrayList)。synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List. synchronizedList 的关键操作上都带有 synchronized。

2.2. 多线程环境使用哈希表

HashMap 本身不是线程安全的,在多线程环境下可以使用Hashtable​或者ConcurrentHashMap。而Hashtable类似于Vector,在方法名上加上synchronized修饰,所以不推荐使用。

java 复制代码
import java.util.Hashtable;

public class Demo6 {
    public static void main(String[] args) {
        Hashtable<String, String> hashtable = new Hashtable<>();
        hashtable.put("111","aaa");
        hashtable.get("111");
    }
}
java 复制代码
public synchronized V put(K key, V value) {
    ......
}

public synchronized V get(Object key) {
    ......
}

ConcurrentHashMap最大的调整就是针对锁的粒度进行可优化。对于Hashtable来说,针对this加锁,任何线程,只要操作这个哈希表都可能触发锁竞争。

两个线程针对同一变量进行修改才会引发线程安全,所以针对哈希表来说,如果两个线程的修改是在不同链表上,线程就是安全的。针对同一链表时,才引入阻塞。在ConcurrentHashMap中,每个链表都有一把锁,称为"锁桶"。由于是不同的锁对象,出发锁竞争的概率就会降低。

在实际中,一个哈希表的桶的个数非常多,针对哈希表的操作,大部分是分布在不同桶上,触发锁竞争的概率可以忽略不计。

ConcurrentHashMap扩容的时候,采取"化整为零"的方案。因为如果哈希表原来的元素很多,扩容会造成很大的开销。为了保证线程安全,必须得加锁,如果全部进行搬运,持有锁的时间比较长,其他线程就无法正常使用哈希表了。