口语化讲解Java基础和集合

鸡汤来了

IO模型来一段

Java中常说的IO模型大致分为五种,分别是BIO、NIO、IO多路复用、信号IO、AIO。首先了解一个概念,这里的IO一般指应用程序向系统内核请求读取数据的操作。

BIO也叫同步阻塞性IO,意思就是应用程序向内核发起读取数据的申请后,自己就阻塞了,直到内核返回数据。

NIO其实就是同步非阻塞IO,它会反复发起申请,直到内核返回数据,这时候应用程序是不阻塞的。

IO多路复用,这个Redis也在用,本质是使用select、poll、epoll函数发起申请,然后等待内核回调通知应用程序数据准备好了,然后应用再发起读取请求获取数据。相较于BIO\NIO优势在于可以同时发起多个读取数据的请求。select有两缺点,一是有1024的最大连接数限制,二是只能知道有数据准备成功,但是不知道具体是哪个监听事件成功,只能遍历查找。poll解决了最大连接数的问题,epoll则是同时解决了两个问题,他们的底层数据结构依次是数组、链表、红黑树加双链表

信号IO则是建立一个信号联系,内核准备好数据后回调通知应用程序去发起读取数据的请求,相较IO多路复用优势在于发起信号联系不会阻塞。

AIO也就是异步IO,应用程序发送读取数据的请求后,不会阻塞,内核准备好数据后直接返回数据给应用程序,相较信号IO又省了第二阶段申请的步骤。

什么是零拷贝

为了保证内核的安全,操作系统一般是将虚拟空间分为用户空间和内核空间,应用程序在用户态不能直接读取硬盘的数据,需要调用系统函数去操作,所以在读取数据时会有状态的切换。用户的一次读请求,会调用read相关的系统函数,然后从用户态切换到内核态,然后CPU通知DMA(直接存储器访问)去磁盘将数据拷贝到内核空间。等到内核缓冲区真正有了数据之后,CPU会把内核缓冲区的数据拷贝到用户缓冲区,再由内核态转换到用户态,由read函数返回用户数据。

零拷贝其实就是将内核缓冲区拷贝到用户缓冲区这一步给省略掉,减少了一次拷贝。常见的优化有mmap和sendfile,mmap在epoll中有使用,其原理是建立内存映射,让内核缓冲区和用户缓冲区实现共享,sendfile在Kafka有应用,这个需要系统底层函数支持才能使用。

聊聊反射

反射是一种在程序运行时动态访问、修改某个类中任意属性和方法的机制。其原理是使用四个核心类,Class.java\Constructor.java\Method.java\Field.java来访问和修改任何类的行为和状态。举两个例子,一是不可变的String类,可以通过反射去修改私有属性,也就是String内部的char数组,从而使不可变的String也可变。这里额外需要注意一点,在修改私有变量或者访问私有方法时,需要调用setAccessible(true)方法关闭安全检查。二是切面中比较常用的,反射去判断方法上有没有指定注解。

除了基本的用法之外,还可以通过优化提高反射效率。一是通过关闭安全检查这个相对耗时的行为来提高效率。二是通过引入高性能反射框架ReflectASM。先介绍ASM,它是一个Java的字节码操作框架,可以动态生成类和增强现有类。ReflectASM通过使用ASM生成原有类的代理类,然后通过新的类进行类上方法调用,速度肯定比传统的反射快得多。

说下ArrayList

ArrayList实现了List接口,是线程不安全的顺序容器,可放入null元素,底层由数组实现,因此需要一组连续的内存空间。常见的泛型实际上是个语法糖,内部是个Object数组,以便接收任何类型的数据。

性能方面,查询支持高效的随机访问,索引下标后可以快速定位。增加和删除的时间复杂度受位置影响,默认添加在末尾很快,如果在中间新增或删除一个元素,则会将该元素后的所有元素统一前推或后移

扩容则是在增加元素之前判断是否超过当前数组长度,如果超过则扩容至当前的1.5倍长度。这里有个小技巧,当我们知道最终扩容大小的时候,可以提前调用扩容方法,避免多次自动扩容来提升性能。

PS(可不提):除此之外,Java集合中大多存在Fail-Fast(快速失败)机制,该机制用于判断集合在使用Iterator迭代器遍历中是否发生了增删全清除等改变长度的操作,如果发生则主动抛出异常中断遍历,避免后续出现问题。其原理很简单,是Iterator内部维护了两个变量,一个modCount一个expectedModCount,一开始两者默认都等于0,当发生增删全清楚等改变长度的操作时,modCount会加一。而每一次循环之前会校验两个变量是否相等,只要不等就会立刻抛出异常。解决方法也很多,比如不用迭代器用fori循环、增删后立刻break停止循环、使用Iterator的删除方法(没有增加方法)、使用JDK8的Stream流。

喔,LinkedList呢

LinkedList实现了List接口和Deque接口,因此非常全能,可以作为顺序容器、队列和栈(ArrayDeque在用作队列和栈是首选,有更好的性能)。

性能方面,不支持高效的随机访问,所有和下标相关的操作都是线性时间,首尾增删则是常数时间。但是因为没有额外的元素转移,因此增删的性能会比ArrayList强。

底层数据结构在1.6及之前是循环双向链表,1.7取消了循环,也就变成了双向链表,每个节点由内部类Node构成,使用first和last分别指向首尾,链表为空时指向null。

ArrayList和LinkedList有什么区别

  1. 底层数据结构不一样,分别是数组和双向链表
  2. 性能不一样,大多时候来说ArrayList相比LinkedList查询快增删慢
  3. 受限于底层数据结构,需要内存空间的类型不一样,ArrayList需要一组连续的内存空间,而LinkedList不需要
  4. ArrayList支持高效的随机访问,通过下标能常数时间内获取数据,而LinkedList不行需要线性时间遍历查询

说一说 PriorityQueue

PriorityQueue 是在 JDK1.5 中被引入的, 其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。

这里列举其相关的一些要点:

  • PriorityQueue 利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据
  • PriorityQueue 通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。
  • PriorityQueue 是非线程安全的,且不支持存储 NULL 和 non-comparable 的对象。
  • PriorityQueue 默认是小顶堆,但可以接收一个 Comparator 作为构造参数,从而来自定义元素优先级的先后。

PriorityQueue 在面试中可能更多的会出现在手撕算法的时候,典型例题包括堆排序、求第 K 大的数、带权图的遍历等,所以需要会熟练使用才行。

ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?

ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:

  • 底层实现:ArrayBlockingQueue 基于数组实现,而 LinkedBlockingQueue 基于链表实现。
  • 是否有界:ArrayBlockingQueue 是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue 创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。
  • 锁是否分离: ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。
  • 内存占用:ArrayBlockingQueue 需要提前分配数组内存,而 LinkedBlockingQueue 则是动态分配链表节点内存。这意味着,ArrayBlockingQueue 在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue 则是根据元素的增加而逐渐占用内存空间。

上点难度,HashMap1.8的put和get方法流程是怎么样的

Put方法流程如下

  1. 判断当前数组是否为空,为空则创建数组
  2. 计算key的hash值,通过(length-1)&hash得到数组下标index
  3. 判断当前数组下标index是否存在数据,不存在则创建Node节点
  4. 存在数据,则说明key的hash值相等,需要进一步判断key是否完全相等,相等的话默认覆盖原值value(onlyIfAbsent为false)
  5. 不相等则表示需要增加节点,此时判断当前节点是否为树形节点,是的话说明当前数据结构为红黑树,则增加一个TreeNode
  6. 不是的话增加一个Node节点,并判断当前链表长度是否超过8,集合元素总数是否超过64,如果超过将链表转换成红黑树
  7. 添加完数据之后判断是否大于阈值,默认是当前集合长度的0.75,如果大于则扩容为原数组长度的两倍

Get方法流程如下

  1. 计算key的hash值,通过(length-1)&hash得到数组下标index
  2. 判断当前下标index的节点key值是否与寻找key完全相等,是的话返回该值
  3. 不是的话,判断节点类型是否为树节点,是的话调用红黑树方法查询
  4. 不是的话,遍历链表查询

HashMap它线程安全吗

HashMap是线程不安全的,1.7中会存在死循环和数据丢失的问题1.8通过尾插法解决了死循环但是还有数据丢失的问题。

死循环的原因是1.7使用了头插法,作者当时认为后插入进来的数据属于热点数据,因此放在头部避免遍历链表从而提升性能。但是在多线程扩容的时候头插法暴露了致命的死循环问题,原因是头插法在扩容的时候,如果ABC三个数据在同一个桶里,再hash后还在同一个桶里的话顺序会倒过来变成CBA。多个线程同时操作的话,在线程上下文切换的时候,有可能造成链表元素指向混乱,导致出现循环链表,从而在使用中产生死循环。1.8使用尾插法则在同一个桶里保持了相同的插入顺序,从尾部插入,不会有指向混乱的情况产生。

数据丢失的问题打个比方,线程A在找到下标准备赋值的时候挂起,线程B在相同下标位置赋值,线程A恢复后继续赋值覆盖了线程B的数据,造成了线程B的数据丢失问题。

要解决这个问题,一般有三个方法,HashTable和Collections.synchronizedMap都是暴力加锁的法子,因此推荐使用JUC并发包下提供的ConcurrentHashMap,内部通过volatile、cas、synchronized提升性能。

HashMap扩容有了解吗

首先了解三个参数,初始长度默认16,负载因子默认0.75,阈值默认是长度*负载因子默认12。扩容一般发生在长度超过阈值的情况下,1.7是先扩容再插值,1.8则相反,扩容都是原长度的两倍

扩容了之后再hash后需要进行元素迁移,1.7的迁移非常简单,遍历数组链表使用头插法迁移即可。1.8通过巧妙的设计极大地提升了性能,因为长度都是2的幂数,且计算数组下标时使用(length-1)&hash公式。2倍的长度在这个二进制运算中实际上就是(length-1)多了一个高位1参与运算。于是非常巧妙的情况出现了,如果hash值最高位是0,那么在原位置不变,如果是1,那么就是原位置+原长度,所以在1.8中不需要重新计算其他数据,只需要判断hash值最高位是0还是1就行了

HashMap在JDK8的优化有哪些

  1. 底层数据结构在数组+链表的基础上增加了红黑树,在过长的链表中时间复杂度为Ologn的红黑树有着更好的性能
  2. Hash算法上1.7使用多个扰动函数来避免哈希碰撞,但是1.8中考虑边际效益不高,因此简化了Hash算法,通过拆分int类型32位的hashcode,用上16位异或下16位进行异或操作得到hash值
  3. 插入新值时1.8使用尾插法避免了1.7中头插法带来的死循环问题
  4. 在元素迁移中1.8通过判断hash值高位是1还是0得到新位置的方法,相比1.7中再hash重定位的一系列运算,大大简化了流程提升了效率

HashMap为什么偏偏要用红黑树,二叉树、平衡二叉树不行吗

红黑树增删改查的最坏时间复杂度都是Ologn,而二叉树极端情况下会退化成On。平衡二叉树则是比红黑树更严格的平衡树,在增删时为了保持平衡,会比红黑树的旋转次数更多,也就是说增删的效率会低于红黑树

LinkedHashMap 继承自 HashMap,它在 HashMap 的基础上,增加了一个双向链表来维护键值对的顺序。这个链表可以按照插入顺序或访问顺序排序,它的头节点表示最早插入或访问的元素,尾节点表示最晚插入或访问的元素。这个链表的作用就是让 LinkedHashMap 可以保持键值对的顺序,并且可以按照顺序遍历键值对。

LinkedHashMap 还提供了两个构造方法来指定排序方式,分别是按照插入顺序排序和按照访问顺序排序。在按照访问顺序排序的情况下,每次访问一个键值对,都会将该键值对移到链表的尾部,以保证最近访问的元素在最后面。如果需要删除最早加入的元素,可以通过重写 removeEldestEntry() 方法来实现。

抽象类和接口的区别?

抽象类

  1. 抽象类使用abstract修饰;
  2. 抽象类不能实例化,即不能使用new关键字来实例化对象;
  3. 含有抽象方法(使用abstract关键字修饰的方法)的类是抽象类,必须使用abstract关键字修饰;
  4. 抽象类可以含有抽象方法,也可以不包含抽象方法,抽象类中可以有具体的方法
  5. 如果一个子类实现了父类(抽象类)的所有抽象方法,那么该子类可以不必是抽象类,否则就是抽象类;
  6. 抽象类中的抽象方法只有方法体,没有具体实现;

接口

  1. 接口使用interface修饰;
  2. 接口不能被实例化;
  3. 一个类只能继承一个类,但是可以实现多个接口;
  4. 接口中方法均为抽象方法;
  5. 接口中不能包含实例域或静态方法(静态方法必须实现,接口中方法是抽象方法,不能实现)

区别

  1. 抽象类可以提供成员方法的实现细节,而接口中没有;但是JDK1.8之后,在接口里面可以定义default方法,default方法里面是可以具备方法体的。
  2. 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型
  3. 接口中每一个方法也是隐式指定为 public abstract不能含有静态代码块以及静态方法,而抽象类可以有静态代码块和静态方法;
  4. 一个类只能继承一个抽象类,而一个类却可以实现多个接口。

使用场景

  • 抽象类用于表示一类事物,抽象概念产品描述。
  • 接口表示一种能力、规范、约束,实现接口表示具有某种能力。

哈希冲突有哪些解决方案

哈希冲突(Hash Collision)是指在哈希表中两个或多个不同的键经过哈希函数计算后映射到相同的哈希桶(哈希表的一个位置)的情况。哈希冲突可能会导致数据存储和检索的效率下降,因此需要解决。以下是一些常见的哈希冲突解决方法:

  1. 开放地址法(Open Addressing)
  • 线性探测法:在发生冲突时,按照一定的步长(通常为1)逐个探测下一个可用的位置。
  • 二次探测法:在发生冲突时,按照二次方程的方式逐步探测下一个位置。
  • 双重散列(Double Hashing):使用第二个哈希函数来计算步长,以找到下一个可用位置。
  1. 链地址法(Chaining)
  • 将哈希表的每个桶(bucket)设置为一个链表或其他数据结构,以容纳多个键值对。当发生冲突时,新的键值对会被添加到链表中。
  • 如果链表变得过长,可以考虑进行链表的扩展或者转化为更高效的数据结构,如红黑树。
  1. 线性探测法的优化
  • 使用二次探测或者双重散列来减少线性探测的线性递增步长,从而减少聚集(Cluster)的问题。
  1. 再哈希(Rehashing)
  • 当哈希表的负载因子达到一定阈值时,可以进行再哈希操作,即创建一个更大的哈希表,然后将所有的键值对重新分布到新表中。这有助于减小冲突的概率。
  1. Cuckoo Hashing
  • Cuckoo Hashing 是一种高效的哈希冲突解决方法,它使用两个哈希函数和两个哈希表。如果发生冲突,键值对会被迁移到另一个哈希表中,直到没有冲突为止。
  1. 分离链接哈希(Separate Chaining Hashing)
  • 这是一种变种的链地址法,但使用了不同的哈希函数来分别哈希到不同的链表,以降低冲突概率。
  1. 线性散列(Linear Hashing)
  • 这是一种渐进式哈希方法,可以在哈希表的大小逐渐增加时解决冲突。

选择适当的哈希冲突解决方法取决于应用的需求和数据分布。不同的方法在不同情况下可能表现更好,因此需要谨慎选择和实施。

写在最后

基础和集合篇简要挑了些重点来说,毕竟我是想做成口语化总结而不是流水账,那有背我的初衷。不过大家可以在评论区提出我没提到的一些重点,我会更新到本篇中。后续是打算一个大模块出一篇文章,简单来说就是把我之前总结的知识点转换成带有思考的口语表达,帮助大家把知识讲出来。八股虽说是背课文,但是真要讲出来还是要有技巧的,需要有一定的理解和思考。我这种算是和网上的问答类文章类似,但是更加浓缩,少了些俏皮话,算是更加正式的表达,拿来即用。最近压力上来了,下一篇文章会相对较快的产出,还有比较多的事情要忙,诸君共勉,新年快乐,愿我们新的一年身体健康、平平安安!

  • 11
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值