一篇文章带你搞懂多线程面试相关的一些问题

目录

1.Callable接口

1.1使用Callable接口来创建线程

1.1相关面试题:

[介绍下 Callable 是什么](#介绍下 Callable 是什么)

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

2.1ReentrantLock

ReentrantLock和sychronized的区别

3.信号量

4.CountDownLatch

5.线程安全的集合类

5.1多线程下使用ArrayList

5.2多线程下使用队列

5.3多线程环境使用哈希表

[5.3.1 Hashtable](#5.3.1 Hashtable)

[5.3.2 CoucurrentHashMap](#5.3.2 CoucurrentHashMap)

5.4一些面试题

5.4.1.ConcurrentHashMap的读是否要加锁,为什么?

5.4.2ConcurrentHashMap在jdk1.8做了哪些优化?

[5.4.3Hashtable和HashMap、ConcurrentHashMap 之间的区别?](#5.4.3Hashtable和HashMap、ConcurrentHashMap 之间的区别?)


1.Callable接口

1.1使用Callable接口来创建线程

我们在创建一个线程的时候有以下几种方法:

1.继承Thread类

2.实现runnable接口

3.使用lambda表达式

4 基于callable

5基于线程池

在这里面 Runnable关注的是过程而不是结果,它并没有返回值,所以如果别的地方要用到它的返回值,就得使用别的办法 如:

java 复制代码
 public static int b = 0;
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                int a = 0;
                for (int i = 1; i <=10 ; i++) {
                    a+=i;
                }
                b = a;
            }
        };
        runnable.run();
        System.out.println(b);
    }

我们希望计算从1~10的累加,但是Runnable接口并没有返回值,所以我们得用一个成员变量来赋值才行,这样写固然可以,但是不够优nia

我们。可以用别的方法来创建一个带返回值的线程:

java 复制代码
 public static void main(String[] args) throws Exception {
//创建的时候可以指定返回值类型
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int a = 0;
                for (int i = 1; i <=10 ; i++) {
                    a+=i;
                }
                return a;
            }
        };
        FutureTask<Integer> futureTask = new FutureTask<>(callable); //使用FutureTask类来接收callable对象
        Thread t = new Thread(futureTask); //将futureTask对象传给Thread里面
        t.start();
        int n = futureTask.get();
        System.out.println(n);
    }

理解Callable

Callable 和 Runnable 相对, 都是描述一个 "任务". Callable 描述的是带有返回值的任务,

Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为

Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.

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

1.1相关面试题:

介绍下 Callable 是什么

Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算

结果.

Callable 和 Runnable 相对, 都是描述一个 "任务". Callable 描述的是带有返回值的任务,

Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为

Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.

FutureTask 就可以负责这个等待结果出来的工作.

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

2.1ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入

ReentrantLock 的用法:

lock(): 加锁, 如果获取不到锁就死等.

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

unlock(): 解锁

java 复制代码
 public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        lock.lock();
        try {
            //主要的逻辑代码
        }finally {
            lock.unlock();
            //注意要释放锁
        }
    }

面试题:

ReentrantLock和sychronized的区别

1.synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).

2。synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,

但是也容易遗漏 unlock.

3.synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就

放弃.

4.synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式

5.更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一

个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指

定的线程.

如何选择使用哪个锁?

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

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

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

3.信号量

信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器

用一个通俗易懂的例子来理解信号量:

信号量就是,我去饭店吃饭,饭店里面的座位数,要是座位被坐满了。那么我就进不去了,一旦有人离开了座位(v操作)那么就是释放资源,这个时候。我就可以进去了,而不是在门口阻塞等待。

而我进去了以后坐上座位,属于申请资源。(p操作),可用资源数(座位个数)就减少了。一旦到了0,那么就不能在被申请了。

Java中 使用Semphore类来封装了信号量机制

代码实例:

首先我们可以不用信号量 用两个线程来分别对count++

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

得到结果:

可以看出和我们预期的结果是不一样的,因为两个抢占式运行,并且count++这个操作并非是原子性的,所以会这样。

接下来我们引用信号量机制 ,并且给它的初始值设为1

java 复制代码
public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore =new Semaphore(1);

        Thread t1 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                count++;
                semaphore.release();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);

无论我们执行多少次 结果都是我们预期的。

4.CountDownLatch

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

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩

构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.

每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.

主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.

java 复制代码
        public static void main(String[] args) throws Exception {
            CountDownLatch latch = new CountDownLatch(10);
            Runnable r = new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep((long) (Math.random() * 10000));
                        latch.countDown();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            };
            for (int i = 0; i < 10; i++) {
                new Thread(r).start();
            }
          // 必须等到 10 人全部回来
            latch.await();
            System.out.println("比赛结束");
        }

5.线程安全的集合类

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

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

5.1多线程下使用ArrayList

1.使用synchronized或者ReentrantLock

2.Collections.synchronizedList(new ArrayList);

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

synchronizedList 的关键操作上都带有 synchronized

3.使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。

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

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

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会

添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

  1. 占用内存较多.

  2. 新写的数据不能被第一时间读取到

5.2多线程下使用队列

1.ArrayBlockingQueue 基于数组实现的阻塞队列 是线程安全的

2,LinkedBlockingQueue 基于链表实现的阻塞队列

3.PriorityBlockingQueue 基于堆实现的 带有优先级的阻塞队列

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

5.3多线程环境使用哈希表

HashMap并不是线程安全的

在多线程下可以使用:

Hashtable

CoucurrentHashMap

5.3.1 Hashtable

把关键方法都加上了synchornized关键字

这相当于直接针对 Hashtable 对象本身加锁.

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

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

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

5.3.2 CoucurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例

读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然

是是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降

低了锁冲突的概率.

充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.

优化了扩容方式: 化整为零

发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.

扩容期间, 新老数组同时存在.

后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小

部分元素.

搬完最后一个元素再把老数组删掉.

这个期间, 插入只往新数组加.

这个期间, 查找需要同时查新数组和老数组

每个链表都有一把单独的锁

5.4一些面试题

5.4.1.ConcurrentHashMap的读是否要加锁,为什么?

不需要锁,目的是为了进一步降低锁冲突的概率,为了保证读到刚修改的关键字,可以使用volatile关键字

5.4.2ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁,直接给每个哈希桶都分配了一把锁。

将原来数组+链表的方式改进成了数组+链表/红黑树的形式。当数组长度大于64,链表长度大于8 到时候就会转变为红黑树

5.4.3Hashtable和HashMap、ConcurrentHashMap 之间的区别?

首先,HashMap并不是线程安全的,而Hashtable和ConcurrenHashMap都是线程安全的。

而Hashtable是很简单粗暴的在它里面的方法上直接加sychornized关键字,并且整个哈希表也只有一个锁。这会引起一系列的性能问题。

CoucurrentHashMap就很聪明的,在哈希表的每个哈希桶(链表)上加上一把锁,并且将扩容这个操作给优化了,它并不是直接一次性扩容到一个新的哈希表中在把旧表里的数据在哈希到新表里,而是在每次插入操作时,分批进行操作。这就避免了突然扩容导致性能需求急剧增加,导致服务器卡死的状况发生。

还有就是HashMap key允许为null

而Hashtable和ConcurrentHashMap的key值是不允许为null的。

相关推荐
公贵买其鹿17 分钟前
List深拷贝后,数据还是被串改
java
LCG元3 小时前
【面试问题】JIT 是什么?和 JVM 什么关系?
面试·职场和发展
xlsw_3 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹4 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭5 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫5 小时前
泛型(2)
java
超爱吃士力架5 小时前
邀请逻辑
java·linux·后端
南宫生5 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石5 小时前
12/21java基础
java
李小白665 小时前
Spring MVC(上)
java·spring·mvc