JAVAEE初阶相关内容第十一弹--多线程(进阶)

发布于:2023-09-16 ⋅ 阅读:(75) ⋅ 点赞:(0)

目录

一、JUC的常见类

1、Callable接口

1.1callable与runnable

1.2代码实例

(1)不使用Callable实现

(2)使用Callable实现

1.3理解Callable

1.4理解FutureTask

2、ReentrantLock

2.1ReentrantLock的用法

2.2ReentrantLock优势

3、原子类

4、Semaphore信号量

4.1理解信号量

5、CountDownLatch

5.1理解CountDownLatch

5.2主要的两个方法

二、线程安全的集合类

1、多线程环境下使用ArrayList

2、多线程环境下使用队列

(1)ArrayBlockingQueue

(2)LinkedBlockingQueue

(3)PriorityBlockingQueue

(4)TransferQueue

3、多线程环境下使用哈希表【重点】

3.1Hashtable

3.2ConcurrentHashMap


一、JUC的常见类

JUC:java.util.concurrent

各种集合类,scanner、random...

concurrent 并发,放了很多并发编程(多线程)相关组件

1、Callable接口

1.1callable与runnable

类似于Rannable 用来描述一个任务

Rannable用来描述一个任务,描述的任务没有返回值。

Callable也是用来描述一个任务,描述的任务有返回值。

如果需要使用一个线程单独的计算某个结果来,此时使用Callable是比较合适的。

1.2代码实例

创建线程计算1到1000的累加和

(1)不使用Callable实现

创建一个类Result,包含一个sum表示最终结果,lock表示线程同步使用的锁对象。

main方法中先创建Result实例,然后创建一个线程t,在线程内部计算1到1000的累加和

主线程同时使用wait等待线程t计算结束(注意如果执行到wait之前,线程t已经计算完了,就不必等待了)

当线程t计算完毕后,通过notify唤醒主线程,主线程再打印结果

代码:

class Result{
    public int sum = 0;
    public Object lock = new Object();
}
public class ThreadD29_1 {
    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        Thread t = new Thread(){
            @Override
            public void run() {
                int sum = 0;
                for (int i = 0; i < 1000; i++) {
                    sum += i;
                }
                synchronized (result.lock){
                    result.sum = sum;
                    result.lock.notify();
                }
            }
        };
        t.start();
        synchronized (result.lock) {
            while(result.sum == 0){
                result.lock.wait();
            }
            System.out.println(result.sum);
        }
    }
}

(2)使用Callable实现

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

对上述代码的一些理解:

(1)不能直接把callable传到Thread中

应该为:

FutureTask:未来的一个任务

get方法就是获取结果

get会发生阻塞,直到callable执行完毕,get才阻塞完成,才获取到结果。

1.3理解Callable

(1)callable和Runnable是相对的,都是描述一个“任务”。Callable描述的是带有返回值的任务Runnable描述的是带有返回值的任务。

(2)Callable通常需要搭配FutureTask来使用。FutureTask用来保存Callabe的返回结果。因为Callable往往是再另一个线程中执行的,啥时候执行完并不确定。

(3)FutureTask就可以负责这个等待结果出来的工作。

1.4理解FutureTask

例如我们在商场吃饭,点餐好了之后后厨就开始做饭,同时在窗口营业员会给你一张“取餐码”,这个“取餐码”就是FutureTask,后面我们可以随时拿着这个取餐码去查看自己的餐食有没有做出来。

2、ReentrantLock

标准库给我们提供的另一种锁。可重入互斥锁,和synchronized定位类似,都是用来实现互斥效果,保证线程安全。

synchronized是直接基于代码的方式来加锁解锁的。

ReentrantLock更传统,使用lock和unlock方法加锁解锁。(最大的问题是unlock可能会执行不到)

建议把unlock放到finally中

2.1ReentrantLock的用法

(1)lock() :加锁,如果获取不到锁就死等【存在return或者异常都可能导致不能顺利执行解锁】

(2)trylock():加锁,如果获取不到锁,等待一定的时间之后就放弃加锁

(3)unlock():解锁

2.2ReentrantLock优势

(1)提供了公平锁版本的实现

(2)ReentranrLock提供了更加灵活的等待方式:tryLock

对于synchronized来说提供的加锁操作就是“死等”,只要获取不到锁,就一直阻塞等待。

无参数版:能加锁就加,加不上锁就放弃。

有参数版:指定了超时时间,加不上锁就等待一会,如果等一会时间到了也没加上就放弃。

(3)ReentrantLock提供了一个更强大,更方便的等待通知机制

synchronized搭配的是wait、notify的时候随机唤醒一个线程。

ReentrantLock搭配的是一个Condition类,进行唤醒的时候可以唤醒指定的线程。

虽然RentrantLock有有一定的优势,但是在一般情况下还是使用synchronized。

3、原子类

原子类内部是使用CAS实现的,所以性能要比加锁实现i++高很多,原子类有以下几个:

原子类
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
AtomiInteger举例--常见方法
addAndGet(int delta); i +=delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incremenrAndGet(); ++i;
getAndIncrement(); i++;

基于CAS,确实是更高效的解决了线程的安全问题,但是CAS不能代替锁,CAS的适用范围有限,不像锁适用的范围广。

4、Semaphore信号量

信号量:用来表示“可用资源”的个数,本质上就是一个计数器。

4.1理解信号量

信号量可以和生活实际相联系。

假设现在在A停车场,当前的车位有100个,表示有100个可用资源,当有车开进去的时候就相当于申请了一个可用资源,,可用车位就-1(这个称为信号量的P操作)。当有车从A停车场开出去的时候,就相当于释放了一个可用资源,可用车位就+1(这个称为信号量的V操作),如果计数器的值已经为0了,还尝试申请资源,就会阻塞等待,直到有其他的线程释放资源。

Semaphore的PV操作中的加减计数操作都是原子的,可以在多线程下直接使用。

实际开发中,虽然锁是最常用的,但是信号量偶尔也会用到,主要是看实际的需求场景。代码中也是可以使用Semaphore来实现类似于锁的效果,来保证线程安全的。

锁可以视为是计数器为1的信号量。二元信号量,锁是信号量的一种特殊情况,信号量就是锁的一般表达。

5、CountDownLatch

简单了解即可,使用的不是特别多,【特定场景】

5.1理解CountDownLatch

首先举一个例子:跑步比赛。开始的时间明确,结束的时间不明确。为了等待这和个跑步比赛结束,引入CountDownLatch。

5.2主要的两个方法

(1)await(wait是等待,a=>all)主线程来调用这个方法

(2)countDown 表示选手冲过了终点线

CountDownLatch在构造的时候,指定一个计数(选手的个数)。

例如指定四个选手进行比赛,初始情况下调用await就会阻塞,每个选手冲过终点就会调用countDown方法。

前三次调用countDown,await没有任何影响

第四次调用countDown,await就会被唤醒返回(解除阻塞)此时就可以认为比赛就结束了。

在实际的开发中,CountDownLatch也是有很多使用场景的,比如下载一个大文件。(视频文件好几个G,把一个大文件切分成好多个小块安排多个线程分别下载)

二、线程安全的集合类

原来的集合类,大部分都是线程不安全的

Vector、Stack、HashTable是线程安全的(不建议用),其他的集合类不是线程安全的。

1、多线程环境下使用ArrayList

(1)自己使用同步机制(synchronnized 或者ReentrantLock)[常见]

(2)Collentions.synchronnizedList(new ArrayList);

这里会提供一些ArrayList相关的方法,同时是带锁的,使用这个方法把 集合类 套一层。

synchronizedList是标准库提供的一个基于synchronized进行线程同步的List。

synchronizedList的关键操作上都带有synchronnized

(3)使用CopyOnWriteArrayList 

CopyOnWrite容器即写时复制的容器“COW”也叫“写时拷贝”

如果针对这个ArrayList进行读操作,不做任何额外的工作。如果进行写操作,则拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作,就继续读旧的这份数据,当修改完毕,使用新的替换旧的(本质上就是一个引用之间的赋值,原子的)

很明显,这种方案优点是不需要加锁,缺点是要求这个ArrayList不能太大,只能适用于这种数组比较小的情况下

服务器的程序进行配置和维护,一个程序可能包含很多子功能,有个功能想要使用,有的功能不想,有的希望应用到不同形态...就可以使用一系列的“开关选项”来控制当前程序的工作状态。

服务器程序的配置文件可能会需要进行修改,修改配置可能就需要重启服务器才能生效,重启服务器的成本还高。因此很多服务器都提供了“热加载” reload。

理解热加载?

不重启服务器实现配置更新。新的配置放在新的对象中,加载过程里,请求依然基于旧的配置工作。当新对象加载完成实验新的对象替代旧对象(替换完成旧对象释放)

小结:

优点:在读多写少的情况下,性能很高,不需要加锁竞争。

缺点:占用内存较多;新写的数据不能被第一时间读取到。

2、多线程环境下使用队列

(1)ArrayBlockingQueue

基于数组阻塞队列实现

(2)LinkedBlockingQueue

基于链表实现的阻塞队列

(3)PriorityBlockingQueue

基于堆实现的阻塞队列

(4)TransferQueue

最多只包含一个元素的阻塞队列

3、多线程环境下使用哈希表【重点】

HashMap本身不是线程安全的

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

更推荐使用的是ConcurrentHashMap,更优化的线程安全哈希表

3.1Hashtable

只是简单的把关键方法加上了关键字synchronized这相当于直接对Hashtable对象本身加锁

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

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

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

如图所示,元素1和元素2在同一个链表上,如果线程A修改元素1,线程B修改元素2,此时就会有线程安全问题。

如果线程A修改元素3,线程B修改元素4,这个就相当于是多个线程修改不同的变量

3.2ConcurrentHashMap

相比于Hashtable做出了一系列的改进和优化【以Java 1.8为例】

(1)最大的优化之处在于CurrentHashMap相比于Hashtable大大缩小了锁冲突的概率,把一把大锁转换成多把小锁。

HashTable做法是直接在方法上加synchronized,等于是给this加锁,只要操作哈希表上的任意元素都会产生加锁,也就都可能发生锁冲突。但是实际上,仔细思考不难发现,基于哈希表的特点,有些元素在进行并发操作的时候,是不会产生线程安全问题的,也不需要使用锁来控制。

上述谈到的情况是针对JDK1.8及其以后的情况,在1.7和之前,ConcurrentHashMap使用的是分段锁。分段锁本质上也是缩小锁的范围,从而降低锁冲突的概率。但是这种做法不够彻底。一方面粒度不够细,另一方面代码实现也更繁琐。

(2)ConcurrentHashMap做了一个激进的操作

针对读操作不加锁,只针对写操作加锁。【但是使用了volatile保证内存读取结果加锁方式依然是用的sunchronized,但是不是锁的整个对象,而是“锁桶”,用每个链表的头结点作为锁对象,大大降低了锁冲突的概率。

读与读之间无冲突

写与写之间有冲突

读与写之间也没有冲突

很多场景下,读写之间不加以控制的话,可能就会读到一个写了一半的结果,如果操作不是原子的,此时读就可能会读到写了一半的数据,相当于脏读。

(3)ConcurrentHashMap内部充分的使用CAS,通过这个也来进一步削减加锁操作的数目。比如维护元素个数

(4)针对扩容,采取“化整为零”的方式

HashMap/HashTable扩容:

创建一个更大的数组空间哦,把旧的数组上的链表上的每个元素搬运到新的数组上(插入+删除)这个扩容会在某次put的时候进行触发。如果元素个数特别多,就会导致这样的搬运操作,比较耗时,就会出现某次put比平时put卡很多倍。

ConcurrentHashMap扩容:

扩容采取的是每次搬运一小部分元素的方式,创建新的数组,旧的数组也保留;每次put操作,就会往新数组中添加,同时进行一部分搬运(把一小部分旧的元素搬运到新数组上),每次get的时候,旧的数组和新数组都查询,每次remove的时候,只是把元素删了就可以。

下一篇将更新这一部分的相关面试题~

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到