目录
场景问题: 你的项目中的点赞功能在高并发场景下该如何优化 ?
List集合下. ArrayList和LinkedList有什么区别?
简介
- 时间: 2024年3月28日19:24:30
- 面试内容
- java基础
- mybatis
- 数据库优化
- java集合体系
- java多线程
- 项目场景
- 设计模式
- 数据结构
- 缓存
提问
数据库的优化策略,你知道哪些?
我的回答:
数据库测优化策略有很多, 比如说合理使用索引, 多去使用聚簇索引, 尽量避免未被最最左匹配原则, 然后在使用索引的时候, 应该避免一些回表的操作, 多使用覆盖索引等.
分析 :
我的这种回答有一个很不好的地方, 就是只是点介绍的不够全面, 给面试官介绍的零零散散, 完全就没有一个顺序逻辑可言, 这个让面试官听起来也会很失望. 所以我们掌握知识, 应该有深度的研究, 有体系的学习, 而不是东拼西凑.
标准回答:
数据库的优化策略主要包括多个方面,旨在提高数据存储和处理的效率,减少系统资源消耗,降低运营成本,以及提升企业的竞争力。以下是一些主要的优化策略:
- 表结构优化:根据实际需求,对数据库表结构进行合理设计,减少数据冗余,提高数据一致性。
- SQL语句优化:编写高效的SQL语句,避免全表扫描,回表等操作,减少不必要的计算和数据访问。可以使用索引、分区等技术来提高查询性能,多使用聚簇索引而非单列索引, 多使用覆盖索引。
- 索引优化:为常用查询字段创建索引,提高查询效率。但要注意避免过度索引,以免增加数据更新的时间复杂度。同时,可以考虑使用多列索引和覆盖索引来进一步提高查询性能。
- 分区与分表:将表数据按照一定规则进行分区或分表,可以提高查询性能和管理效率。分区可以将一张表的数据分散到多个物理存储位置,而分表则可以将一张大表拆分成多个小表。
- 硬件设备升级:使用高速的缓存存储(如RAM或SSD)来存放频繁访问的数据,而将不常用的数据存放在较慢的存储设备上。此外,增加内存、使用多核CPU等也可以提高数据库性能。
- 数据库软件优化:针对数据库软件本身进行优化,如调整数据库参数、优化查询计划等。
- 存储过程与函数:使用存储过程和函数来封装复杂的SQL逻辑,减少网络传输开销,提高执行效率, 例如一个常规的点赞操作, 那么被点赞的内容会被插入到关系表中, 并且被点赞的点赞数会 + 1, 这个时候如果将其分为两步, 那么就会有两次网络传输的开销, 但是如果使用存储过程, 那么在点赞之后, 存储过程自动识别并 + 1操作, 就不需要再次进行网络传输。
- 定期维护:定期对数据库进行清理、备份和维护,保证数据库的稳定性和可用性。同时,定期更新索引统计信息,帮助数据库优化查询计划。
- 数据缓存: 如果短时间内有大量的sql语句执行, 那么必然会造成数据库崩溃, 我们应该尽量避免这种情况, 首先考虑到使用redis, mq等作为数据缓存, 让其异步的形式刷新到内sql中去, 这样就避免了短时间内的大量请求, 减少了sql数据库崩溃的可能性.
总之,数据库优化是一个综合性的过程,需要根据实际情况选择合适的优化策略。在优化过程中,要注意权衡各种因素,确保优化效果的最佳化。
分析:
这种答法很有逻辑性, 首先数据库的优化, 我们应该从需求的角度出发, 去分析这张表的设计与实现, 在设计的时候, 就应该避免一些冗余字段, 到后面要对这个sql进行操作的时候, 就去优化操作sql的sql语句, 优化语句就涉及到一些索引, 计算, 和数据访问的只知识, 然后细说索引里面的如何去优化索引.
如果这些都是最高效的了, 还想继续优化, 那么就可以考虑分库分表, 在存储的时候进行一些取模操作来决定数据的存储与查询, 这样就减少了查询的时间.
还想要升级的话, 那么就加钱, 升级设备, 更好的设备, 比如网卡的读写速度, 好的硬盘, 来或者内存和cpu, 来提高数据处理的速度.
硬件说完了就来说软件, 例如mysql, sqlserver等主流数据库中的软件优化, 或者软件解决方案, 例如存储过程等等.
软硬件说完了, 剩下的就是好好地去维护这些表和库, 定期对其进行一个清理, 清理掉一些僵尸数据.
自己的软件说完了, 就说说别人的软件呗? 比如redis, 他的精髓就是内存的非关系型数据库, 性能高. 使用其作为数据缓存, 或者是热点数据的存储, 让sql底层压力更小.
怎么样, 这样的回答, 可算仔细? 而且这样的回答更有体系, 顺理成章, 面试官也会为你点赞的.
场景问题: 你的项目中的点赞功能在高并发场景下该如何优化 ?
我的一个论坛系统的项目, 里面涉及到一个点赞的功能, 我的实现方法是, 用户请求点赞之后, 将请求发送给controller层, 然后controller层调用service层, 然后service层进行相关校验工作之后, 调用dao层去插入一条sql记录, 然后同步执行sql操作给对应的帖子点赞数 + 1 操作 . 这个时候 我底层用户和用户自己点赞的帖子靠着一张表实现了, 用户点赞就插入, 取消点赞就删除相关记录即可, 但是今天面试官问了我一个问题, 如果高并发情况下, 同时有大量用户对同一个帖子点赞, 该怎么处理 ?
场景问题有时候确实头疼, 因为它牵扯到一些底层, 当然也牵扯到你的业务处理能力, 这会直接让面试官给你判决定分. 本人是面试小白(假), 下面看看小白我是怎么回答的.
我的回答:
我考虑到的是, 如果这种高并发情况系, 会涉及到大量的用户对同一个帖子进行点赞, 每一次点赞的操作都会发送一个http请求,给后端的接口, 后端接收到之后, 就去对应的数据库中插入当前点赞用户的记录(也就是标记这个帖子被当前用户点赞), 但是大量的请求就意味着大量的请求会直接发送给sql数据库, 那么数据库肯定会承受不住而崩溃, 我的解决办法是引入数据缓存, 例如redis, 将这些数据以热点数据的形式发布, 然后同步到sql, 或者是将redis作为sql操作缓存, 将操作异步刷新给sql, 这样sql处理起来就避免了过多的网络请求, 避免了数据库崩溃.
分析:
各位认为我说的对吗??? 哈哈哈, 我这个人当然对自己也是直言不讳啦, 我说的对, 虽然答到点子上面去了, 但是, 还不够深入,同时也不够体系. 光耀深的, 浅的不要嘛? 肯定要啊? 这个点应该从表的设计, 索引的设计, sql语句的设计等底层开始一步步实现.
标准回答:
在高并发情况下,大量用户同时对同一个帖子点赞确实会带来挑战,主要涉及数据库并发写入性能、数据一致性和并发控制等方面。以下是一些建议来应对这个问题:
- 数据库优化:
- 索引优化:确保对用于查询的帖子ID和用户ID字段建立了适当的索引,以提高查询和写入性能。
- 批量插入:如果可能,考虑使用批量插入的方式减少数据库交互次数,提高性能。
- 存储过程: 如果可以, 使用存储过程, 将多个修改插入操作, 合并为一次交互的过程, 避免多次交互.
- 使用合适的数据库引擎:比如,如果使用的是MySQL,可以考虑使用InnoDB等支持事务的存储引擎。
- 缓存策略:
- 读缓存:使用Redis等内存数据库缓存帖子点赞数,减少直接对数据库的读取操作。
- 写缓存:对于写操作,也可以考虑先写入缓存,再异步同步到数据库,以减轻数据库的压力。
- 并发控制:
- 乐观锁:使用乐观锁机制(如版本号控制)来确保在并发更新时数据的一致性。
- 悲观锁:使用数据库的行锁或表锁来确保数据在更新过程中的一致性,但需要注意避免死锁问题。
- 消息队列:(同缓存)
使用消息队列(如Kafka、RabbitMQ等)来异步处理点赞操作,将请求先放入队列,再由后台服务异步处理,减轻前端请求对数据库的直接压力。- 限流与降级:
- 限流:通过限流策略(如令牌桶算法、漏桶算法等)控制对数据库的请求频率,避免过多的请求同时涌入。
- 降级:在极端情况下,可以考虑降级服务,比如暂时关闭点赞功能,或返回缓存中的旧数据, 或者是是限制用户的单个时间内的点赞的次数。
- 分布式解决方案:
- 如果单数据库实例无法满足性能要求,可以考虑使用分布式数据库解决方案,如分片、读写分离读写等。
- 业务逻辑优化:
- 去重:在业务逻辑层面确保同一个用户短时间内对同一个帖子只能点赞一次,避免重复操作。
- 合并请求:对于短时间内多次的点赞请求,可以考虑在服务器端合并处理,减少数据库操作次数。
- 监控与报警:
- 实施完善的监控和报警机制,实时监控系统的性能指标(如QPS、响应时间、错误率等),及时发现并处理潜在问题。
- 通过报警系统及时通知相关人员,以便快速响应和解决问题。
在回答面试官的问题时,可以综合以上策略进行说明,并强调根据具体的业务场景和系统架构来选择适合的解决方案。同时,也可以提及在实际项目中如何通过压力测试、性能分析和监控等手段来发现和解决高并发问题。
我们的设计中, 最应该注意的就是第三点, 并发控制, 这一点也是经常要考的, 为什么? 因为我们在帖子点赞的时候, 需要对点赞数进行一个+ 1 操作, 然后向后端插入一个记录, 这两步都涉及到sql的交互, 因此其他几个都是在与sql交互的层面进行优化, 但是我们还应该保持数据一致性和多线程的安全问题. 例如 多个线程+1操作, 最后的结果可能会小于 正确结果. 这是不允许发生的. 于是我们可以使用锁的机制来控制:
// 使用数据库事务来确保操作的原子性
BEGIN TRANSACTION;
// 读取当前的点赞数
int currentLikes = getPostLikes(postId);
// 增加点赞数
currentLikes++;
// 更新帖子的点赞数
updatePostLikes(postId, currentLikes);
COMMIT TRANSACTION;
如果你不熟悉这个语言, 那么下面是java的代码案例:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
public class PostService {
private final Lock lock = new ReentrantLock();
@Autowired
private PostRepository postRepository; // 假设有一个对应的JPA仓库
@Transactional
public void likePost(Long postId) {
lock.lock(); // 获取锁
try {
Post post = postRepository.findById(postId).orElse(null);
if (post != null) {
post.setLikes(post.getLikes() + 1);
postRepository.save(post);
}
} finally {
lock.unlock(); // 释放锁
}
}
}
这一段代码中使用了锁操作, 其核心操作就是, 根据当前用户的id, 获取帖子, 然后获取帖子的点赞数, 给其进行 + 1然后存入数据库, 我们知道spring中这个service的bean是交给ioc容器管理的, 这个时候, 多个请求回到同一个方法中执行, 就会出现并发情况, 如下:
你的两次操作, 本应该将数据的like数, 变为 12, 但是由于多线程安全问题, 他的最终结果为10, 这就是为什么要加锁的原因.
此外为什么要加事务管理@Transactional呢? 因为这个save操作到数据库中, 可能会出现多个save并发执行的情况, 也就是可能出现上面的, set 同一个post的like数, 这个时候, 他们可能的sql语句为:
UPDATE posts SET likes = likes + 1 WHERE id = ?;
如果不加事务,直接执行UPDATE posts SET likes = likes + 1 WHERE id = ?;
这条SQL语句,可能会产生以下几种后果:
-
更新成功:如果数据库连接正常,且没有其他并发问题,该语句会正常执行,将指定帖子的点赞数加一。
-
部分成功或失败:在并发环境下,如果没有适当的事务和锁机制,多个请求可能同时尝试更新同一个帖子的点赞数。这可能导致最终点赞数不是所有请求期望的累积结果。例如,两个请求几乎同时到达,每个请求都读取了相同的初始点赞数,然后都基于这个数进行了加一操作。如果这两个操作都成功,最终的点赞数会比预期的多加一次。
-
异常导致更新失败:如果在执行更新操作的过程中发生异常(如数据库连接断开、SQL语法错误、权限问题等),那么更新操作会失败,帖子的点赞数不会发生变化。
-
数据不一致:在没有事务的情况下,如果更新操作被中断(如服务器崩溃),可能会导致数据处于不一致的状态。例如,如果点赞数已经加一,但随后由于某种原因操作被中断,那么这个“部分完成”的更新可能会被保留在数据库中。
-
并发问题:并发控制是数据库管理中的一个重要方面。没有事务,就难以确保数据的一致性和隔离性。这可能导致脏读、不可重复读、幻读等并发问题。
List集合下. ArrayList和LinkedList有什么区别?
这个问题的频率真的是太高了, 建议大家好好掌握...
首先我们来看看他们两个都实现了什么接口:
这是LinkedList的:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
// arraylist的具体代码, 这里省略
}
这是ArrayList的:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
// 省略
}
首先映入眼帘的就是一些奇奇怪怪的接口. 一些不熟悉java基础的人可能会很迷糊, 接下来我一一解答:
- List<E>
List
接口是 Java 集合框架的一部分,它代表一个有序的集合(也称作序列)。List
允许存储重复的元素,并且可以通过元素的索引(位置)来访问元素。List
接口提供了一系列方法来操作元素,如add(E e)
,get(int index)
,remove(int index)
,size()
等。LinkedList和ArrayList
作为List
的一个实现,自然提供了这些功能。
- Deque<E>
Deque
(双端队列)接口是 Java 集合框架的一部分,它支持在两端插入和移除元素。Deque
接口提供了诸如:addFirst(E e)
,addLast(E e),
removeFirst()
,removeLast()
,peekFirst()
,peekLast()
等方法。因为LinkedList
内部是双向链表结构,所以它很适合实现Deque
接口,这样它就可以作为双端队列使用,支持从两端进行插入和删除操作。
- Cloneable
Cloneable
接口是一个标记接口,它本身并不包含任何方法。当一个类实现了Cloneable
接口,并覆盖了Object
类中的clone()
方法,那么该类的实例就可以被克隆(即创建该对象的一个浅拷贝)。对于LinkedList
来说,这意味着你可以创建一个LinkedList
的副本,包含与原始LinkedList
相同的数据,但它们是两个独立的对象。
- java.io.Serializable
Serializable
接口也是一个标记接口,用于标识类的实例可以被序列化。序列化是将对象的状态转换为可以存储或传输的形式的过程。当对象实现了Serializable
接口,它就可以被转换为字节流,以便写入持久存储或通过网络发送。之后,这些字节流可以被反序列化回对象。对于LinkedList
来说,实现Serializable
接口意味着你可以将LinkedList
的实例保存到文件或通过网络发送,然后在需要时恢复它。
- RandomAccess
RandomAccess
是一个标记接口,主要用于向编译器和其他开发人员传达信息。它本身并不包含任何方法定义,只是作为一个标记,用于表示实现了该接口的类在访问元素时具有较好的性能。当一个类实现了RandomAccess
接口,它通常意味着该类的实例可以通过索引直接访问集合中的元素,而无需按顺序遍历整个集合。这种随机访问能力在大型数据结构中可以显著提高代码的执行效率。通过实现这些接口,
LinkedList
类提供了丰富的功能和灵活性,使其适用于多种不同的应用场景。
标准回答:
首先看看它们的相同点:
- 都实现了List接口, 他们都 提供了一系列方法来操作元素,如
add(E e)
,get(int index)
,remove(int index)
,size()
等- 线程安全:无论是ArrayList还是LinkedList,它们都不是线程安全的。如果在多线程环境下使用,需要额外的同步措施来保证线程安全。
- 他们两个都是有序列表的表达.
- 都允许查询相同的元素
- 在创建的时候都可以指定一个初始化容量, 但是这不是必须的
我们更可能会关注其不同点, 不同点是经常考察的知识点,
下面我们聊聊其不同点:
- 底层数据结构:
ArrayList底层维护的是一个数组, 而LinkedList底层维护的是一个双向链表, LinkedList因此在除了List集合提供的方法的实现之外, 还具有双端队列的一些操作, 例如pop,pull等.- 性能不同, 首先这个性能不能一概而论, 为什么?
单论插入操作, 如果在ArrayList的尾部插入, 那么它涉及到的代码为:public boolean add(E e) { ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }
也就是在确定容量之后, 直接在尾部进行一个添加就可以多么简单, LinkedList也很简单, 如下:
public boolean add(E e) { linkLast(e); return true; }
void linkLast(E e) { final Node<E> l = last; final Node<E> newNode = new Node<>(l, e, null); last = newNode; if (l == null) first = newNode; else l.next = newNode; size++; modCount++; }
这里的last是?
看英文解释相比都能知道, 它维护了一个指向队首和队尾结点的两个指针. last就是最后的尾结点, 上面的LinkedList尾插就是直接使用这个已经记录的last点来插入, 它两这种插入的操作时间复杂度都是O1, 效率很高.
但如果不是在队尾呢? 比如说队首插入:
下面是ArrayList在队首插入:
public void add(int index, E element) { rangeCheckForAdd(index); ensureCapacityInternal(size + 1); // Increments modCount!! System.arraycopy(elementData, index, elementData, index + 1, size - index); elementData[index] = element; size++; }
里面涉及到了一个arraycopy操作,也就是去copy 这个数组的index下标, 给他放到elementdata数组的index + 1下标, 其时间复杂度为On, 但是LinkedList就不一样了, 它维护了一个队首指针, 头插直接找到头部元素插入就可以, 时间复杂度为O1, 下面是ArrayList头插的方法:
public void addFirst(E e) { linkFirst(e); }
private void linkFirst(E e) { final Node<E> f = first; final Node<E> newNode = new Node<>(null, e, f); first = newNode; if (f == null) last = newNode; else f.prev = newNode; size++; modCount++; }
平均但是如果我要是假设链表和数组的长度都很大, 那么头插尾插的概率就很小, 因此他们平均起来的插入的时间复杂度其实都是 (1 + n) / 2, 也就是On, 因为他们都需要遍历一次列表, 好的情况就是遍历一次, 不好的情况都是遍历n次, 平均就是O n
删除操作也同样如此, 可以自己翻阅源码..
- 访问
但是他们的访问速度却又很大的区别, 对于ArrayList, 它支持数据的随机访问, 也就是你知道下标, 就可以直接读取对应下标的数据, 这种使用索引的方法时间复杂度O1, 但是LinkedList就不一样了, 它没有维护这样的索引, 只是维护了一个队首队尾的指针, 对其他数据的查询效果很一般需要遍历整个链表来查找, 时间复杂度为On
- 容量问题
ArrayList扩容是一点五倍扩容, 例如10容量, 下次扩容就是15, 它存在一个问题就是, 如果数据量非常大的时候进行扩容, 就会扩容出许多空闲空间, 如果对资源比较紧张的情况下, 这无疑是一个不好的 地方. 但是LinkedList不会额外开销出不使用的额外空间, 但是存储每一个数据, LinkedList会比ArrayList开销大, 因为他需要维护每一个结点的前一个结点和后一个结点.
== 和 equals的区别
有些b, 一看到这个问题, 瞬间就会想: 哎呀这题我会, 超级简答, 这下可以给面试官一个好的映像了, 结果随便说了两句:
== 是直接比较对象的内存地址, 然后通过重写的equals是比较的对象是否相等, 这个相等的判断逻辑是重写equals手动提供的.
你心里还在乐呵呵, 但是面试官已经要被你无语到了.... 如果我问一句, equals 是哪里来的? 你可能都会懵逼, 哈哈
首先我们要知道equals是Object类中提供的一个方法:
/**
* Indicates whether some other object is "equal to" this one.
* <p>
* The {@code equals} method implements an equivalence relation
* on non-null object references:
* <ul>
* <li>It is <i>reflexive</i>: for any non-null reference value
* {@code x}, {@code x.equals(x)} should return
* {@code true}.
* <li>It is <i>symmetric</i>: for any non-null reference values
* {@code x} and {@code y}, {@code x.equals(y)}
* should return {@code true} if and only if
* {@code y.equals(x)} returns {@code true}.
* <li>It is <i>transitive</i>: for any non-null reference values
* {@code x}, {@code y}, and {@code z}, if
* {@code x.equals(y)} returns {@code true} and
* {@code y.equals(z)} returns {@code true}, then
* {@code x.equals(z)} should return {@code true}.
* <li>It is <i>consistent</i>: for any non-null reference values
* {@code x} and {@code y}, multiple invocations of
* {@code x.equals(y)} consistently return {@code true}
* or consistently return {@code false}, provided no
* information used in {@code equals} comparisons on the
* objects is modified.
* <li>For any non-null reference value {@code x},
* {@code x.equals(null)} should return {@code false}.
* </ul>
* <p>
* The {@code equals} method for class {@code Object} implements
* the most discriminating possible equivalence relation on objects;
* that is, for any non-null reference values {@code x} and
* {@code y}, this method returns {@code true} if and only
* if {@code x} and {@code y} refer to the same object
* ({@code x == y} has the value {@code true}).
* <p>
* Note that it is generally necessary to override the {@code hashCode}
* method whenever this method is overridden, so as to maintain the
* general contract for the {@code hashCode} method, which states
* that equal objects must have equal hash codes.
*
* @param obj the reference object with which to compare.
* @return {@code true} if this object is the same as the obj
* argument; {@code false} otherwise.
* @see #hashCode()
* @see java.util.HashMap
*/
public boolean equals(Object obj) {
return (this == obj);
}
我不知道有多少人读过上面这几句话, 读过的扣1
简单来解释一下, 就是:
- 表示其他对象是否“等于”这个对象。
- equals方法在非空对象引用上实现一个等价关系:
- 它是自反的:对于任何非空引用值x,x.equals(x)应该返回true。
- 它是对称的:对于任何非空引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)应该返回true。
- 它是传递的:对于任何非空引用值x,y和z,如果x.equals(y)返回true并且y.equals(z)返回true,那么x.equals(z)应该返回true。
- 它是一致的:对于任何非空引用值x和y,多次调用x.equals(y)一致返回true或一致返回false,前提是对象上用于等价比较的信息没有被修改。
- 对于任何非空引用值x,x.equals(null)应该返回false。
- Object类的equals方法在对象上实现最具区分度的可能等价关系;也就是说,对于任何非空引用值x和y,当且仅当x和y引用相同的对象时,此方法返回true(x == y的值为true)。注意,当这个方法被重载时,通常需要重载hashCode方法,以维护hashCode方法的通用契约,该契约规定相等的对象必须具有相等的哈希码。
- 参数: obj - 要比较的引用对象。
- 返回值: 如果这个对象与obj参数相同,则返回true;否则返回false。
- 参见: hashCode(), java.util.HashMap
== 是一种运算符号而已,它具有两个操作数, 他有自己的运算规则.
标准回答:
- 定义与用途:
==
是一个运算符,用于比较两个对象或值的引用是否相等,或者对于基本数据类型,比较它们的值是否相等。equals()
是Object类中的一个方法,用于比较两个对象的内容是否相等。这个方法在Object类中的默认实现是比较对象的引用地址是否相等,但在许多子类(如String、Integer等)中,这个方法通常会被重写以比较对象的内容。- 比较的对象:
- 对于基本数据类型(如int、double等),
==
直接比较它们的值是否相等。- 对于引用类型(如String、Object等),
==
比较的是引用地址是否相同,即它们是否指向内存中的同一个对象。equals()
方法则用于比较引用类型对象的内容是否相等,这通常需要在子类中重写该方法以实现正确的比较逻辑。- 运行速度:
==
通常比equals()
方法运行得更快,因为它只是简单地比较引用或值,不需要进行复杂的内容比较。equals()
方法可能需要执行更复杂的逻辑来比较对象的内容,因此运行速度可能较慢。- 空值处理:
- 当使用
==
比较一个对象与null时,如果对象本身是null,那么比较结果为true。equals()
方法通常在实现时会先检查传入的对象是否为null,以避免NullPointerException。- 可重写性:
==
运算符是固定的,不能被重写。equals()
方法是Object类的一部分,可以在任何子类中被重写以提供自定义的相等性判断逻辑。
你了解HashMap吗?
(呵呵, 不了解 .. )
下面是HashMap的类的定义:
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// ....
}
- Map<K,V>
Map
是一个接口,它表示一种键值对的映射关系。HashMap
实现了Map
接口,因此它必须提供Map
接口中定义的所有方法,如put(K key, V value)
,get(Object key)
,remove(Object key)
,containsKey(Object key)
等。Map
接口提供了用于操作键值对的基本方法,如添加、删除和查找键值对。
2. Cloneable
Cloneable
是一个标记接口,它本身没有定义任何方法。如果一个类实现了Cloneable
接口,那么它的实例可以被克隆。HashMap
实现了Cloneable
接口,这意味着你可以使用Object
类的clone()
方法来创建一个HashMap
的浅拷贝。需要注意的是,clone()
方法默认是受保护的,因此你可能需要在HashMap
或它的子类中重写它以提供公共访问。
3. Serializable
Serializable
也是一个标记接口,用于表示一个类的实例可以被序列化。序列化是将一个对象的状态转换为字节流的过程,以便可以将它写入持久存储或通过网络发送。如果HashMap
的实例需要被持久化或在网络中传输,那么实现Serializable
接口就是必要的。需要注意的是,尽管HashMap
实现了Serializable
接口,但由于其内部可能包含不可序列化的对象,因此在序列化时可能会抛出NotSerializableException
。为了避免这种情况,你需要确保HashMap
中的所有键值对都是可序列化的。
HashMap的初始容量和负载因子:
HashMap在创建时可以指定初始容量(默认为16)和负载因子(默认为0.75)。负载因子用于确定何时调整HashMap的大小,即当HashMap中的数据量达到其容量的某个比例时,会进行扩容,以优化性能.
当然它是一个线程不安全的集合.
HashMap的数据结构:
在JDK 1.8之前,HashMap内部采用数组+链表进行存储。而在JDK 1.8及以后的版本中,HashMap的数据结构有所改进,内部采用了数组+链表+红黑树的组合。这种改进主要是为了解决哈希冲突以及提高性能。
当HashMap中的某个桶(bucket)的链表长度超过一定的阈值(默认为8),并且当前数组的长度大于64时,这个桶的所有数据会改为使用红黑树来存储。红黑树的引入主要是为了提高查询效率,因为在链表较长时,查询的时间复杂度为O(n),而红黑树的查询时间复杂度为O(log n)。
HashMap中的数组是一个Node类型的数组,每个Node对象都保存了键值对的信息,包括键(key)、值(value)、哈希值(hash)以及指向下一个Node的引用(next)。由于next的存在,每个Node对象都可以看作是单向链表中的一个节点。
此外,HashMap还涉及到哈希函数和哈希冲突的处理。哈希函数用于将键转换为数组的索引,而哈希冲突则是指不同的键可能计算出相同的哈希值,从而映射到数组的同一个位置。HashMap通过链地址法(链表)来处理哈希冲突。
初始化容量和扩容:
判断是否需要扩容:当HashMap中的元素数量超过了当前容量与负载因子的乘积时,就会触发扩容操作。负载因子是一个衡量HashMap何时应该扩容的阈值,其默认值通常为0.75。
计算新的容量:一旦确定需要扩容,HashMap会计算出一个新的容量。在JDK 1.8及以后的版本中,新的容量通常是当前容量的两倍。
创建新的数组:根据计算出的新容量,HashMap会创建一个新的Node数组来替换旧的数组。
重新计算哈希值并重新插入元素:扩容后,HashMap会遍历原数组中的每个元素,重新计算它们的哈希值,并将这些元素按照新的哈希值插入到新的数组中的合适位置。这个过程称为“rehashing”。
替换旧的数组:当所有的元素都被重新插入到新的数组中后,旧的数组会被垃圾回收,新的数组成为HashMap的存储结构。
需要注意的是,扩容操作可能会比较耗时,因为它涉及到大量元素的迁移和重新计算哈希值。因此,在创建HashMap时,如果能合理地预估未来的数据量,并设置一个合适的初始容量,可以有效减少扩容的次数,从而提高性能。
此外,由于HashMap不是线程安全的,如果在多线程环境下使用HashMap,并且多个线程同时修改HashMap,可能会导致扩容操作出现并发问题。这种情况下,应该考虑使用线程安全的ConcurrentHashMap类来替代HashMap。
关于为什么扩容的倍数是2 ?
首先,翻倍操作在计算机中是非常高效的,因为它只是简单地调整二进制表示中的某一位。这种操作比进行复杂的数学计算来确定新的容量要简单得多,因此可以节省大量的计算时间。
其次,翻倍可以确保新的容量有足够的空间来容纳更多的元素,而不会很快再次触发扩容操作。如果每次扩容只增加一小部分容量,那么随着元素的不断增加,扩容操作的频率会非常高,这将严重影响HashMap的性能。而翻倍操作可以在一定程度上减少扩容的频率,从而提高HashMap的整体性能。
此外,HashMap的扩容不仅仅是简单地更换一个更大的数组,还需要重新计算所有元素的哈希值,并将它们插入到新的数组中。这是一个相对耗时的操作,因此减少扩容的频率也可以减少这种性能开销。
线程的生命周期
这一个知识点是操作系统层面的, 当然如果你java学得好, 你肯定也知道了解线程的生命周期..
这里直接上概念了, 不多赘述了, 自己去看操作系统..
标准回答:
线程的生命周期通常分为以下五个阶段:
- 新建状态:当程序通过new关键字创建出来的线程,该线程就处于新建状态。
- 就绪状态:当线程调用start()方法以后,该线程就处于就绪状态。但这并不代表该线程就可以执行了,而是需要去争夺时间片,谁争夺到了时间片就可以执行。
- 运行状态:当处在就绪状态的线程获取到了CPU资源时,随后就会自动执行run()方法,该线程就进入了运行状态。
- 阻塞状态:处在运行状态的线程,可能会因为某些原因而导致处在运行状态的线程就会变成阻塞状态。例如,当线程执行了sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程变成阻塞状态, 此时线程就会放弃CPU的执行权。当sleep()方法时间片到了或者阻塞方式结束时,线程就会重新转入就绪状态。
- 死亡状态:线程结束运行后,会进入死亡状态。线程可以因正常运行结束而自然死亡,也可以通过调用某些方法(如interrupt)来强制结束,使其进入死亡状态。
值得注意的是,线程的调度不是跨平台的,它不仅仅取决于Java虚拟机,还依赖于操作系统。Java的线程调度是不分时的,同时启动多个线程后,不能保证各个线程轮流获得均等的CPU时间片。
了解线程的生命周期对于编写高效且稳定的多线程程序至关重要,因为不同的线程状态可能会影响程序的执行顺序和性能。开发者需要仔细管理线程的状态,以确保程序能够正确、高效地运行。
java多线程中, sleep和wait有什么区别?
在我们的计算机操作系统中, wait和sleep都会让线程暂停执行. 但是他们还是有区别的..
1. 所属的类
sleep方法所属的类是Thread类的一个静态方法, 这就意味着, 所有的线程对象都可以调试它, 而wait是Object的一个类的方法, 因此所有的对象都可以调用它, 由于每个对象内部都有一个内置的锁, 因此wait和notify, notifyAll方法进场与锁一起使用, 以协调线程间的通信
2. 释放锁
当线程调用了sleep方法的时候, 她不会立即释放持有的锁, 这就意味着其他线程无法访问该线程持有的资源, 即使线程正在休眠.
但是wait方法它会释放它持有的所有的锁对象, 其他线程就有机会获取该对象释放的锁, 当wait方法返回的时候, 现成需要重新获取锁才能继续运行
3. 唤醒机制
sleep方法可以指定一个时间参数, 在指定时间之内后自动被唤醒并继续执行, 他不能被其他的线程中断或者是提前唤醒.
wait方法没有指定的时间参数, 现成调用wait之后会进入阻塞状态, 一直到其他的线程调用同一对象的notify或者是notifyAll的时候, 或者是发生中断超时.
4. 异常处理
sleep方法会抛出InterruptedException, 现成如果在休眠期间被中断, wait方法也可能抛出InterruptException, 如果线程在等待期间被中断, 此外还可能抛出IllegalMonitorStateException异常, 如果当前线程不是此对象的监视器所有者
5. 用途
sleep方法是短暂让县城暂停执行一段时间, 而不需要与其他线程之间进行协调和通信
但是wait和notify等方法主要用于线程之间的通信和同步, 特别是在生产者消费者模型中.
你了解红黑树吗
红黑树(Red Black Tree)是一种自平衡二叉查找树,它在计算机科学中作为一种重要的数据结构被广泛使用,特别是用于实现关联数组。红黑树在1972年由Rudolf Bayer首次提出,当时被称为平衡二叉B树(Symmetric Binary B-Trees)。后来在1978年,Leo J. Guibas和Robert Sedgewick对其进行了修改,形成了如今所说的红黑树。
红黑树具有以下几个关键特性和性质:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色的。
- 所有叶子节点(NIL节点或空节点)都是黑色的。
- 如果一个节点是红色的,那么它的两个子节点都是黑色的(即不能有两个相邻的红色节点)。
- 对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点,这被称为黑色节点的“路径长度相等”。
- 红黑树的最长路径不会超过最短路径的两倍,确保了红黑树的高度始终保持在对数级别。
这些性质使得红黑树在插入、删除和查找操作中都能保持相对平衡,从而保证了高效的性能。红黑树的平均和最坏情况下的时间复杂度都是O(log n),其中n是树中元素的数目。
红黑树在实际应用中有着广泛的用途。例如,在C++标准模板库(STL)中,map和set容器的底层实现就是基于红黑树的,这使得它们可以高效地进行元素的插入、删除和查找操作。此外,红黑树也被用于文件系统中管理文件的目录结构,以便快速地进行文件的查找和访问。在交易策略开发中,红黑树可以用于管理交易订单、优化交易策略的执行效率以及管理交易风险。
TCP和UDP的区别
TCP(Transmission Control Protocol,传输控制协议)和UDP(User Datagram Protocol,用户数据报协议)都是网络协议中的传输层协议,它们各自具有不同的特性和适用场景。
TCP是一种面向连接的、可靠的、基于字节流的传输层通信协议。它旨在适应支持多网络应用的分层协议层次结构,为不同但互连的计算机通信网络的主计算机中的成对进程之间提供可靠的通信服务。TCP在传输过程中使用确认和重传机制来确保数据的完整性和正确性,对数据的可靠性要求非常严格。然而,这也使得TCP通常比UDP的速度更慢,并且在网络拥堵时,由于拥塞控制机制,其传输速度会进一步下降。
相比之下,UDP则是一种无连接的传输层协议。它基于IP协议,提供了一种将数据包发送到网络上的方式,但并不保证数据包的可靠性、顺序性和完整性,也不提供拥塞控制和流量控制等功能。因此,UDP对数据的可靠性要求较低,发送方不需要提前与接收方建立连接,可以直接向接收方发送数据。这使得UDP在速度和效率方面通常比TCP更快,尤其适用于高速传输和实时性较高的场合,如即时通信和视频通信等。然而,由于UDP不保证数据的可靠性,如果在传输过程中出现丢包,可能会导致接收方收到的数据不完整。
总的来说,TCP和UDP各有其优缺点,选择使用哪种协议取决于具体的应用需求和网络环境。对于需要可靠传输和对数据完整性有严格要求的情况,通常会选择TCP;而对于需要高速传输和实时性较高的场合,UDP则可能更为合适。
你了解哪些设计模式?
设计模式是在软件开发中经常遇到的一些问题的最佳解决方案。它们已经被广大开发者证明是有效的,并且可以被重复使用来解决类似的问题。设计模式使得代码更加可重用、可维护和可扩展。下面列举了一些常见的设计模式,它们可以按照三种主要的类型来分类:创建型、结构型和行为型。
创建型设计模式:
- 单例模式:确保一个类仅有一个实例,并提供一个全局访问点。
- 工厂模式:定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。
- 抽象工厂模式:提供一个接口,用于创建相关或依赖对象的家族,而不需要明确指定具体类。
- 建造者模式:将一个复杂对象的构建与其表示分离,使得同样的构建过程可以创建不同的表示。
- 原型模式:用原型实例指定创建对象的种类,并且通过拷贝这个原型来创建新的对象。
结构型设计模式:
- 适配器模式:将一个类的接口转换成客户端所期望的另一种接口,从而使得原本由于接口不兼容而无法协同工作的类能够协同工作。
- 桥接模式:将抽象部分与它的实现部分分离,使它们都可以独立地变化。
- 组合模式:允许你将对象组合成树形结构以表示“部分-整体”的层次结构,使得客户端对单个对象和复合对象的使用具有一致性。
- 装饰器模式:动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。
- 外观模式:为子系统中的一组接口提供了一个统一的接口,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
- 享元模式:运用共享技术来有效地支持大量细粒度对象的复用。
行为型设计模式:
- 策略模式:定义了一系列的算法,并将每一个算法封装起来,使它们可以互相替换。策略模式使得算法可以独立于使用它的客户端变化。
- 模板方法模式:定义了一个操作中的算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重新定义该算法的某些特定步骤。
- 观察者模式:定义对象之间的一对多依赖关系,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。
- 迭代器模式:提供一种方法顺序访问一个聚合对象中的各个元素,而又不需要暴露该对象的内部表示。
- 状态模式:允许一个对象在其内部状态改变时改变它的行为。对象看起来好像修改了它的类。
- 职责链模式:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。
- 访问者模式:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。
- 中介者模式:用一个中介对象来封装一系列的对象交互,中介者使各对象不需要显式地相互引用,从而使其耦合松散,而且可以独立地改变它们之间的交互。
- 解释器模式:给定一个语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。
这些设计模式是软件开发中的宝贵财富,掌握它们可以帮助开发者更加高效地解决各种问题,提高代码的质量和可维护性。
如何保证懒汉模式的线程安全
懒汉模式是一种延迟化设计模式, 其特点就是第一次需要该对象时才进行初始化, 但是在java中如果多个线程同时尝试初始化这个对象, 就可能会出现线程安全的问题. 为了保证懒汉模式的线程安全,我们可以使用双重检查锁定(double-checked locking)或者静态内部类的方式。
双重检查锁定
public class Singleton {
// 使用volatile关键字保证instance的可见性
private static volatile Singleton instance;
private Singleton() {
// 私有构造函数,防止外部通过new Singleton()创建对象
}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
这里为什么需要双重 if判断??
原因是首先假设有两个线程会分别读调用getInstance方法, 就会产生如下情况:
两个线程同时进行了if判断instance是否为空, 然后一个线程拿到锁, 另外一个线程等待, 这个时候, 先拿到锁的那个线程给instance实例化之后, 就释放锁并返回, 然后另外一个线程拿到锁之后, 就如果没有双重判断, 那么就会直接给instance再次一次new操作, 就会产生线程不安全的情况.
但是仅仅只是一个双重判断, 还是不够用, 因为你的修改只是在工作内存中修改, 并未被同步到总内存中去, 为了确保这种修改对于其他线程也是可见的, 那么就应该允许线程修改本地缓存的变量, 如果不使用volatile. 那么一个线程修改了同步块中的instance之后, 其他线程就会无法看到这个修改, 从而导致线程安全问题.
静态内部类
public class Singleton {
private Singleton() {
// 私有构造函数,防止外部通过new Singleton()创建对象
}
// 静态内部类,在第一次调用getInstance()时才会被加载
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
在这个例子中,SingletonHolder
是一个静态内部类,它包含了一个静态的Singleton
对象INSTANCE
。由于Java的类加载机制保证了静态内部类只会被加载一次,因此这种方式也是线程安全的。同时,由于这种方式只有在第一次调用getInstance()
方法时才会加载SingletonHolder
类,因此也实现了懒加载的效果。