【多线程】JUC(java.util.cuncurrent)

文章目录

  • 1. Callable接口
  • 2. ReentrantLock(可重入锁)
  • 3. Semaphore(信号量)
  • 4. CountDownLatch
  • 5. 线程安全的集合类
  • 6. ConcurrentHashMap
      • 6.1 缩小了锁的粒度
      • 6.2 引入了CAS原子操作
      • 6.3 扩容的优化
  • 7. 总结 HashTable, HashMap, ConcurrentHashMap 之间的区别
      • 7.1. 线程安全性
      • 7.2. null 键和 null 值的支持
      • 7.3. 性能
      • 7.4. 扩容机制
      • 7.5. 使用场景

JUC就是放了一些多线程编程时有用的类

1. Callable接口

Callable和Runnable类似,只不过Callable的call方法是有返回值的,而Runnable的run方法没有返回值。
Runnable关注的是执行过程,不关注执行结果,Callable关注执行结果
案列:计算1+2+3+…+1000
Runnable实现:

    //Runnable注重过程,没有返回值
    //需要我们自己定义static变量将结果存储到变量中然后打印
    private static int sum=0;
    public static void main(String[] args) throws InterruptedException {
        Thread t=new Thread(new Runnable() {
            int result=0;
            @Override
            public void run() {
                for (int i=1;i<1000;i++){
                    result+=i;
                }
                sum=result;
            }
        });
        t.start();
        t.join();
        System.out.println(sum);

Callable实现:

 public static void main(String[] args) throws InterruptedException, ExecutionException {
        Callable<Integer> callable=new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result=0;
                for (int i=1;i<=1000;i++){
                    result+=i;
                }
                return result;
            }
        };
        //FutureTask相当于一个"粘合剂",先将callable存储到FutureTask,然后将FutureTask存储到Thread中
        FutureTask futureTask=new FutureTask(callable);
        Thread t=new Thread(futureTask);
        t.start();
        //futureTask的字面意识是未来要执行的任务
        System.out.println(futureTask.get());
    }

注意:

在Thread类中的构造方法中没有传入Callable的,此时我们就需要另一个类 FutureTask 来作为Thread和Callable之间的 “粘合剂”
FutureTask类是一个还未执行任务的类,在未来会执行任务的类,当未来执行任务的时候,就把这个FutureTask作为标记,作为执行任务的标记
futureTask.get() 方法也是带有阻塞功能的,如果线程没有执行完毕,get就会阻塞,等线程执行完毕,return回来结果,就会被get返回

2. ReentrantLock(可重入锁)

ReentrantLock的锁风格就是传统的锁风格,需要lock和unlock来加锁解锁。这种加锁解锁的弊端就是可能在遇到return或者抛出异常的时候忘记解锁。

这个ReentrantLock是历史遗留的,在synchronized还没有变厉害之前的产物,在synchronized兴起之后基本就没有使用了。

为什么有synchronized还要用ReentrantLock呢?

因为ReentrantLock中的有些操作是比synchronized更有用
1.ReentrantLock提供了 trylock 操作,lock在直接加锁过程中,没加上锁就要阻塞,trylock在加锁过程中,即使加锁不成,也不会阻塞,直接返回false。
2.ReentrantLock提供了公平锁(通过设置参数就可以设计公平锁)。
3.搭配的等待通知机制不同,对于synchronize使用的是wait()和notify(),对于ReentrantLock搭配Condtion类,性能更好一点。

3. Semaphore(信号量)

信号量就是用来表示"可用资源的个数"。

就像是停车场门口的电子牌,上面会显示还有多少个车位,开出一个车,数字就+1,进去一个车数字就-1,信号量也是同样的原理。如果"可用资源个数"为0,那么释放资源(V操作)就会进入阻塞。

PV操作:

如果申请一个资源,就会使数字-1,这种操作叫P操作
如果释放一个资源,就会使数字+1,这种操作叫V操作

如果这个信号量为0了,那么此时再进行P操作,这个P操作就会阻塞等待。这个信号量Semaphore是操作系统内部提供的一个机制,在操作系统内部对应的API被JVM封装,之后交由Java代码来调用。

由此可见,锁也是一种特殊的信号量,这个锁是一种计数值为1的信号量

所谓的锁,本身就是一种特殊的信号量,可以认为就是计数值为1的信号量,当调用Semaphore中的acquire()方法的时候申请一个资源,此时"可用资源的个数"为0,那么就进入阻塞,当调用Semaphore中的release()方法时释放一个资源,此时"可用资源的个数"为1,通过阻塞达到加锁解锁的效果。

当处于释放态度的时候,就是1
当处于加锁状态的时候,就是0

这种与加锁相关的非0即1的信号量被称之为二元信号量

import java.util.concurrent.Semaphore;
public class SemaphoreDemo {
    public 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();//P操作
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();//V操作
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();//P操作
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();//V操作
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count: " + count);
    }
}

4. CountDownLatch

把一个大任务拆分成小任务,由每个线程分别执行。就像多线程下载,把一个很大的下载的文件拆分成多个部分,每个线程负责下载一个部分,最后整合到一起。
像这样的场景,必须要都全部下载完成之后再整合,所以使用CountDownLatch就可以很好感知到是否已经全部下载完毕。

CountDownLatch 维护了一个计数器,初始值为一个正整数,当调用 countDown() 方法时,计数器值会递减 1。如果调用 await() 方法的线程发现计数器的值大于 0,调用方法的线程会一直阻塞,直到计数器的值变为 0。

import java.util.Random;
import java.util.concurrent.CountDownLatch;
public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        //意味着有10个线程/任务
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread thread = new Thread(() -> {
                Random random = new Random();
                int time = (random.nextInt(5)+1)*1000;
                System.out.println("线程"+id+"开始下载");
                try {
                    Thread.sleep(time);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("线程"+id+"结束下载");
                countDownLatch.countDown();
            });
            thread.start();
        }
        countDownLatch.await();//等待所有线程执行完毕
        System.out.println("所有线程执行完成");
    }
}

5. 线程安全的集合类

Java中的集合类如ArrayList、Queue、HashMap等都是线程不安全的,而Vector、Stack、Hashtable虽然是线程安全的,它们内置了synchronized但是在多线程环境下也是不推荐使用的。

在多线程环境下使用集合类,如何避免线程安全问题?

自己加锁
使用带锁的List:通过该方法得到的新对象里面的关键方法都是带有锁的
CopyOnWrite(写时拷贝):如果要修改数据,就会先将表复制一份,然后修改数据,将原来表中的引用指向新的表。例如:一个顺序表,多个线程读取表中的数据的时候肯定是没有线程安全问题的,但是一旦有线程修改里面的值,就可能会有线程安全问题

List<Integer> list= Collections.synchronizedList(new ArrayList<>());
List<Integer> list = Collections.synchronizedList(new LinkedList<>());

6. ConcurrentHashMap

ConcurrentHashMap的优点:

缩小了锁的粒度
充分地使用了CAS原子操作,减少了一些加锁
针对扩容的优化
对读操作做了处理,1和2是针对写操作进行处理,针对读操作通过volatile等确保读取的数据不是“半成品”

6.1 缩小了锁的粒度

Hashtable的加锁是直接给put、get方法加锁(也就是给this加锁),整个的Hashtable对象就是一把锁,任何一个对该对象的操作都会发生锁竞争。

而ConcurrentHashMap不一样,ConcurrentHashMap是给每个哈希表中的链表进行加锁,也就是说它不是一把锁,而是很多把锁。
【多线程】JUC(java.util.cuncurrent)_第1张图片
如果多个线程同时修改一个链表,会有线程安全问题;如果多个线程同时修改不同的链表,此时线程就不会有问题。

6.2 引入了CAS原子操作

ConcurrentHashMap引入了CAS原子操作,修改size(哈希表中的元素个数)这样的操作是不会加锁的,而是借助CAS完成。

6.3 扩容的优化

这个扩容是哈希表的重量级操作。
在哈希表中有一个负载因子:这个负载因子描述了每一个哈希桶上平均有多少个元素个数。

负载因子=实际的元素个数/数组的长度 或者 哈希桶中的元素个数

同时把这个负载因子的值和0.75作比较,如果这个负载因子超过了0.75,那就需要进行扩容。
这个0.75被称为默认的负载因子的扩容阈值。
而且哈希表的时间复杂度一直都是O(1),为了保证这个时间复杂度一直都是O(1),需要通过控制负载因子的大小来保证让哈希桶上的链表的元素个数不可以太长,如果这个哈希表上的链表的元素实在是太长,那就有以下两个选择:

变成树(长度不平均的情况)
进行扩容操作

扩容操作:创建一个新的更大的数组,然后把旧的哈希桶上的元素全部都给搬运(搬运的本质就是插入/删除)到新的数组上。

如果本身这个哈希表上的元素非常多,就会导致这里的扩容操作消耗很长的时间,而且我们无法控制什么时候会出现扩容的操作,一旦扩容操作触发之后,速度就会变慢。而我们的哈希表的特点是时间复杂度O(1),平时使用哈希表的速度都很快,突然这里扩容的时候就慢了下来,在扩容完毕之后又变快了,此时这个哈希表的表现就很不稳定。

面对上述扩容导致的哈希表不稳定的情况,我们的ConcurrentHashMap就提出了解决方案:

化整为零,蚂蚁搬家

在HashMap的扩容的过程中,都是一次性全部给扩容完毕,在HashMap中一旦触发了扩容的操作,那么此时消耗的时间就是不稳定的。而ConcurrentHashMap中如果触发了扩容的操作,此时的扩容就不是一次性全部扩容完,而是化整为零,每次都只是扩容一小部分的元素,确保每次操作的速度不会变慢。比如在原表中有2千万的元素需要扩容,那么就进行多次的扩容操作,每次都只是扩容一部分,在搬运(插入/删除)的时候时操作一小部分的元素,每次只搬运5K个元素,一共花费2K次的操作来搬运完成。以此来确保每次操作消耗的时间不长,确保速度快。
在实际开发中,我们的扩容操作的触发频率是很低的,当程序开始运行的时候,可能一天都不会触发一次扩容操作(前提是我们提前设置好了容量)。

7. 总结 HashTable, HashMap, ConcurrentHashMap 之间的区别

HashTableHashMapConcurrentHashMap 都是 Java 中用于存储键值对的集合类,它们的实现方式和用途有所不同,具体区别如下:

7.1. 线程安全性

  • HashTable:是线程安全的,它的所有方法都使用了 synchronized 关键字,因此在多个线程同时访问时可以保证线程安全。但由于使用了同步,它的性能较差,尤其是在高并发场景下。
  • HashMap:不是线程安全的。如果多个线程同时访问并修改 HashMap,则可能会出现不一致的结果。因此,在并发访问时需要额外的同步处理。
  • ConcurrentHashMap:是线程安全的,但它并不像 HashTable 那样对整个映射进行同步,而是采用了分段锁(Segment Locking)机制。它在多个线程读取和写入时能够提供更好的并发性能。

7.2. null 键和 null 值的支持

  • HashTable:不允许使用 null 作为键或值。如果尝试使用 null,会抛出 NullPointerException
  • HashMap:允许使用 null 作为键或值。可以有一个 null 键和多个 null 值。
  • ConcurrentHashMap:不允许使用 null 作为键或值。如果尝试使用 null 作为键或值,会抛出 NullPointerException

7.3. 性能

  • HashTable:由于是线程安全的,性能较差。每次操作都要加锁,导致性能瓶颈。
  • HashMap:由于是非线程安全的,在单线程或不需要并发安全的情况下,它比 HashTable 更高效。
  • ConcurrentHashMap:通过分段锁机制,可以在多线程环境下提供较好的并发性能,相较于 HashTable,它在高并发情况下性能更高。

7.4. 扩容机制

  • HashTable:在扩容时会锁住整个表,因此扩容操作在多线程环境下可能会导致性能下降。
  • HashMap:扩容是自动的,且不需要锁,性能较好。但是在多线程情况下可能存在数据不一致的问题。
  • ConcurrentHashMap:扩容机制较为复杂,它通过分段锁来进行扩容操作,能够在多线程环境下保持良好的并发性能。

7.5. 使用场景

  • HashTable:适用于不需要并发操作的场景,或者需要老旧版本兼容性的情况。
  • HashMap:适用于单线程或不需要线程安全的场景,通常在大多数应用中使用。
  • ConcurrentHashMap:适用于需要高并发、安全访问的场景,尤其是在多线程环境下,能够提供比 HashTable 更好的性能。

总结来说,HashMap 是常用的非线程安全实现,ConcurrentHashMap 提供了高效的线程安全机制,而 HashTable 在现代应用中较少使用,因为它的性能较低且已经被 ConcurrentHashMap 替代。

你可能感兴趣的:(java,开发语言)