余声-个人博客


  • 首页

  • 分类

  • 归档

  • 标签

👌什么是缓存穿透

发表于 2025-05-06 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌什么是缓存穿透?

口语化回答

好的,面试官,缓存穿透的最核心就是当高并发请求来的时候,但是 key 在缓存中不存在的时候,就会请求数据库,如果数据库还是没有的话,就会返回,但是这个时候,由于没有数据,也不会存入到缓存中,下次请求过来还会重复这个操作。如果说这个 key 一直没有数据,就会不断的打到数据库中。这就是缓存穿透。缓存穿透主要可以通过缓存空值,布隆过滤器的方式来进行解决。常用的就是缓存空值,当数据库也查询不到的时候,在缓存中将空值写入,这样后面的请求就会命中缓存,不会造成数据库的大压力。布隆过滤器比较适合一些固定值,来进行初步的过滤,这样可以减少误判率,同时减轻压力,以上。

题目解析

redis 算是必问的三个概念之一,其他两个是缓存击穿和缓存雪崩,后面也有介绍。这道题很多人容易把缓存穿透和缓存击穿来弄乱。要注意好识别,还有就是三种常见的解决方案要理解透彻。

面试得分点

穿透的核心概念,缓存空对象解决,布隆过滤器,缓存预热。

题目详细答案

缓存穿透是指在高并发场景下,如果某一个key被高并发访问,但该key在缓存中不存在,那么请求会穿透到数据库查询。如果这个key在数据库中也不存在,就会导致每次请求都要到数据库去查询,给数据库带来压力。严重的缓存穿透会导致数据库宕机。可以根据图看到核心的重点在于不命中和返回空。解决方案也围绕这些即可。

解决方案

1、 缓存空对象

当数据库中查不到数据时,缓存一个空对象(例如一个标记为空或不存在的对象),并给这个空对象的缓存设置一个过期时间。这样,下次再查询该数据时,就可以直接从缓存中拿到空对象,从而避免了不必要的数据库查询。

这种解决方式有两个缺点:

需要缓存层提供更多的内存空间来缓存这些空对象,当空对象很多时,会浪费更多的内存。

会导致缓存层和存储层的数据不一致,即使设置了较短的过期时间,也会在这段时间内造成数据不一致问题。比如缓存还是空对象,这个时候数据库已经有值了。这种引入复杂性,当数据库值变化的时候,要清空缓存。

1
2
3
4
5
6
7
8
9
10
11
String key = "jichiKey";
String value = redis.get(key);
if (value == null) {
value = database.query(key);
if (value == null) {
// 缓存空结果,设置短过期时间
redis.set(key, "", 60); // 60秒过期
} else {
redis.set(key, value, 3600); // 1小时过期
}
}

2、 使用布隆过滤器

布隆过滤器用于检测一个元素是否在集合中。访问缓存和数据库之前,先判断布隆过滤器里面有没有这个 key,如果 key 存在,可以继续往下走,如果 key 不存在,就不用往下进行走了。比较适合数据 key 相对固定的场景。可以减少误识别率。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
BloomFilter<String> bloomFilter = new BloomFilter<>(expectedInsertions, falsePositiveProbability);
// 初始化布隆过滤器,插入所有可能存在的键
for (String key : allPossibleKeys) {
bloomFilter.put(key);
}

// 查询时使用布隆过滤器
String key = "jichiKey";
if (!bloomFilter.mightContain(key)) {
// 布隆过滤器判断不存在,直接返回
return null;
} else {
// 布隆过滤器判断可能存在,查询缓存和数据库
String value = redis.get(key);
if (value == null) {
value = database.query(key);
redis.set(key, value, 3600); // 1小时过期
}
return value;
}

3、缓存预热

在系统启动时,提前将热门数据加载到缓存中,可以避免因为请求热门数据而导致的缓存穿透问题。需要根据系统的实际情况和业务需求来判断是否需要对缓存进行预热。比如在一些高并发的系统下,提前预热可以大大减少毛刺的产生,以及提高性能和系统稳定。

缓存预热的经典代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.jingdianjichi.redis.init;

import org.springframework.stereotype.Component;

@Component
public abstract class AbstractCache {

public void initCache(){}

public <T> T getCache(String key){
return null;
}

public void clearCache(){}

public void reloadCache(){
clearCache();
initCache();
}

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class InitCache implements CommandLineRunner {

@Override
public void run(String... args) throws Exception {
//我要知道哪些缓存需要进行一个预热
ApplicationContext applicationContext = SpringContextUtil.getApplicationContext();
Map<String, AbstractCache> beanMap = applicationContext.getBeansOfType(AbstractCache.class);
//调用init方法
if(beanMap.isEmpty()){
return;
}
for(Map.Entry<String,AbstractCache> entry : beanMap.entrySet()){
AbstractCache abstractCache = (AbstractCache) SpringContextUtil.getBean(entry.getValue().getClass());
abstractCache.initCache();
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Component
public class CategoryCache extends AbstractCache {

private static final String CATEGORY_CACHE_KEY = "CATEGORY";

@Autowired
private RedisUtil redisUtil;

@Autowired
private RedisTemplate redisTemplate;

@Override
public void initCache() {
//跟数据库做联动了,跟其他的数据来源进行联动
redisUtil.set("category","知识");
}

@Override
public <T> T getCache(String key) {
if(!redisTemplate.hasKey(key).booleanValue()){
reloadCache();
}
return (T) redisTemplate.opsForValue().get(key);
}

@Override
public void clearCache() {
redisTemplate.delete(CATEGORY_CACHE_KEY);
}
}

👌什么是缓存雪崩?

发表于 2025-05-06 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌什么是缓存雪崩?

口语化回答

好的,面试官,缓存雪崩主要是在同一时间,系统大量缓存失效,这个时候的大量请求都要打到数据库,增加了数据库压力,导致数据库崩溃或者不可用,一般如果产生了雪崩,就是比较严重的后果。雪崩主要的解决方案,一方面是设置合理的缓存过期时间,不要让同一时间失效,尽量的分散。另一个方案就是可以在比如服务刚启动的时候,进行缓存的预热,防止刚启动的时候,大量请求打到数据库。另一方面也要提升缓存架构的高可用,避免因为缓存服务的问题,导致请求打到数据库。以上。

题目解析

经典的缓存三大问题之一。问的也是挺多的,面试官主要是想知道你对他的理解,相比击穿和穿透,雪崩很好理解。要注意的一个点就是雪崩相对应的解决方案要重点记一下。

面试得分点

同时过期失效,设置分散过期时间,热点数据预加载,高可用

题目详细答案

缓存雪崩是指在某一时刻,大量缓存数据同时失效,导致大量的请求直接穿透到数据库,瞬间给数据库带来巨大的压力,可能导致数据库崩溃或服务不可用。

雪崩的常见原因

  1. 缓存数据过期时间相同:当缓存系统中大量数据的过期时间被设置为同一时间点或相近的时间段时,这些数据会同时失效,从而引发缓存雪崩。
  2. 缓存服务器故障:当缓存服务器发生故障时,如果没有有效的容错机制,缓存中的数据将无法被访问,系统可能直接请求后端服务或数据库,导致系统性能下降。

解决方案

设置合理的缓存过期时间

缓存过期时间的设置需要根据业务需求和数据的变化频率来确定。对于不经常变化的数据,可以设置较长的过期时间,以减少对数据库的频繁访问。对于经常变化的数据,可以设置较短的过期时间,确保缓存数据的实时性。总之就是尽量打散缓存的过期时间,最好做到均匀的时间分布,减轻系统同一时刻的压力。

使用热点数据预加载

预先将热点数据加载到缓存中,并设置较长的过期时间,可以避免在同一时间点大量请求直接访问数据库。可以根据业务需求,在系统启动或低峰期进行预热操作,将热点数据提前加载到缓存中。

热点数据预加载可以提升系统的性能和响应速度,减轻数据库的负载。

缓存高可用

缓存做成集群的形式,提高可用性,防止缓存挂掉后,造成的穿透问题。

当缓存服务器发生故障或宕机时,需要有相应的故障转移和降级策略。可以通过监控系统来及时发现缓存故障,并进行自动切换到备份缓存服务器。同时,可以实现降级策略,当缓存失效时,系统可以直接访问数据库,保证系统的可用性。通过缓存故障转移和降级策略,可以保证系统在缓存不可用或故障的情况下仍然可以正常运行,提高系统的稳定性和容错性。

👌使用redis统计网站的uv,应该怎么做

发表于 2025-05-06 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌使用redis统计网站的uv,应该怎么做

题目详细答案

常见的是使用Set数据结构和HyperLogLog数据结构。

使用Set统计UV

Set是一种集合数据结构,可以存储不重复的元素。将每个访客的唯一标识(如用户ID或IP地址)添加到Set中,可以很方便地统计独立访客数。

  1. 记录访客访问:每次有访客访问时,将其唯一标识添加到当天的Set中。
  2. 获取UV:使用SCARD命令获取Set中元素的数量,即为独立访客数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import redis.clients.jedis.Jedis;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class UVTrackerSet {
private Jedis jedis;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

public UVTrackerSet(String redisHost, int redisPort) {
this.jedis = newJedis(redisHost, redisPort);
}

public void recordVisit(String userId) {
String date= LocalDate.now().format(DATE_FORMATTER);
String key="uv:set:" + date;
jedis.sadd(key, userId);
// 设置键的过期时间为30天,防止内存无限增长
jedis.expire(key, 30 * 24 * 60 * 60);
}

public long getUV(String date) {
String key="uv:set:" + date;
return jedis.scard(key);
}

public long getUVRange(String startDate, String endDate) {
LocalDatestart= LocalDate.parse(startDate, DATE_FORMATTER);
LocalDateend= LocalDate.parse(endDate, DATE_FORMATTER);

String[] keys = start.datesUntil(end.plusDays(1))
.map(date -> "uv:set:" + date.format(DATE_FORMATTER))
.toArray(String[]::new);

StringtempKey="uv:set:range";
jedis.sunionstore(tempKey, keys);
longuvCount= jedis.scard(tempKey);
jedis.del(tempKey);
return uvCount;
}

public static void main(String[] args) {
UVTrackerSet tracker = new UVTrackerSet("localhost", 6379);

// 记录访客访问
tracker.recordVisit("user_123");
tracker.recordVisit("user_456");

// 获取指定日期的UV
Stringtoday= LocalDate.now().format(DATE_FORMATTER);
System.out.println("UV for " + today + ": " + tracker.getUV(today));

// 获取一段时间内的UV
StringstartDate="2023-07-01";
StringendDate="2023-07-07";
System.out.println("UV from " + startDate + " to " + endDate + ": " + tracker.getUVRange(startDate, endDate));
}
}

使用HyperLogLog统计UV

HyperLogLog是一种概率性数据结构,可以在固定的内存空间内提供高效的基数估计。它适合处理大规模数据。

  1. 记录访客访问:每次有访客访问时,将其唯一标识添加到当天的HyperLogLog中。
  2. 获取UV:使用PFCOUNT命令获取HyperLogLog的基数估计。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import redis.clients.jedis.Jedis;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
public class UVTrackerHLL {

private Jedis jedis;
private static final DateTimeFormatter DATE_FORMATTER= DateTimeFormatter.ofPattern("yyyy-MM-dd");

public UVTrackerHLL(String redisHost, int redisPort) {
this.jedis = newJedis(redisHost, redisPort);
}

public void recordVisit(String userId) {
Stringdate= LocalDate.now().format(DATE_FORMATTER);
Stringkey="uv:hll:" + date;
jedis.pfadd(key, userId);
// 设置键的过期时间为30天,防止内存无限增长
jedis.expire(key, 30 * 24 * 60 * 60);
}

public long getUV(String date) {
Stringkey="uv:hll:" + date;
return jedis.pfcount(key);
}

public long getUVRange(String startDate, String endDate) {
LocalDatestart= LocalDate.parse(startDate, DATE_FORMATTER);
LocalDateend= LocalDate.parse(endDate, DATE_FORMATTER);

String[] keys = start.datesUntil(end.plusDays(1))
.map(date -> "uv:hll:" + date.format(DATE_FORMATTER))
.toArray(String[]::new);

StringtempKey="uv:hll:range";
jedis.pfmerge(tempKey, keys);
longuvCount= jedis.pfcount(tempKey);
jedis.del(tempKey);
return uvCount;
}

public static void main(String[] args) {
UVTrackerHLLtracker=newUVTrackerHLL("localhost", 6379);

// 记录访客访问
tracker.recordVisit("user_123");
tracker.recordVisit("user_456");

// 获取指定日期的UV
Stringtoday= LocalDate.now().format(DATE_FORMATTER);
System.out.println("UV for " + today + ": " + tracker.getUV(today));

// 获取一段时间内的UV
StringstartDate="2023-07-01";
StringendDate="2023-07-07";
System.out.println("UV from " + startDate + " to " + endDate + ": " + tracker.getUVRange(startDate, endDate));
}
}

优缺点对比

set hyperloglog
精准度 精确统计,无误差 存在一定误差(通常在0.81%左右)
占用内存 内存占用较大,尤其是当访客数量很大时 内存占用小,通常只需要12KB内存。
内存占用情况 小数据量,同时对内存不敏感可以 适合大规模数据

👌如果有大量的key需要设置同一时间过期,一般需要注意什么?

发表于 2025-05-06 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌如果有大量的key需要设置同一时间过期,一般需要注意什么?

题目详细答案

大量的 key 同一时间过期,就是非常常见的缓存雪崩场景。

缓存雪崩是指在同一时间大量缓存key同时失效,导致大量请求直接涌向数据库或后端服务,可能引发系统崩溃或性能严重下降。

雪崩解决方案

过期时间随机化:在设置过期时间时,添加一个随机的偏移量,使得不同key的过期时间稍微不同,避免在同一时刻大量key同时失效。

1
2
3
4
5
Random random=new Random();
int baseExpiry=3600; // 基础过期时间,单位为秒
int randomOffset= random.nextInt(300); // 随机偏移量,最大300秒
int finalExpiry= baseExpiry + randomOffset;
redisClient.set(key, value, finalExpiry);

分散过期时间:根据业务逻辑,将key的过期时间分散在不同的时间段内。例如,可以根据key的某些属性(如用户ID、商品ID等)分散设置过期时间。

缓存预热:在缓存失效前,提前预热缓存,确保缓存中始终有数据。

监控报警机制

使用Redis自身的监控工具或第三方监控工具(如Prometheus、Grafana等)监控缓存的命中率、延迟、内存使用等指标。设置报警规则,当缓存命中率下降或延迟增加时,及时发送报警通知,便于快速定位和解决问题。

👌看门狗机制的原理是什么?

发表于 2025-05-06 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌看门狗机制的原理是什么?

口语化答案

在我们平常使用分布式锁的时候,一般会设置一个锁的过期时间,那么如果锁过期的时候,业务还没执行完怎么办,于是就有了看门狗。看门狗机制是一种用于自动续约分布式锁的机制,确保在持有锁的客户端处理完业务逻辑之前,锁不会过期。当客户端获取到锁时,会在 Redis 中设置一个键和一个过期时间(默认30秒)。同时,Redisson 会启动一个后台任务,这个任务会定期检查锁的状态。看门狗任务会每隔一段时间,默认是锁的过期时间的1/3,即10秒,检查锁的状态。如果锁仍然被持有,看门狗任务会将锁的过期时间重置为初始值。这样,锁的过期时间不断被延长,直到客户端明确释放锁或者客户端挂掉。以上。

题目解析

看门狗机制非常常问,一旦问到分布式锁的问题,就会产生这个问题,当你说出异常情况下,传统的分布式锁,采取过期时间释放,防止死锁的时候,就会引入到这个问题。

面试得分点

后台任务续约,高可靠,自动管理

题目详细答案

Redisson 的看门狗机制是一种用于自动续约分布式锁的机制,确保在持有锁的客户端处理完业务逻辑之前,锁不会过期。比如,我们平时使用分布式锁的时候,一般会设置一个锁的过期时间,那么如果锁过期的时候,业务还没执行完怎么办,于是就有了看门狗。

看门狗机制的原理

初始锁定:

当客户端获取到锁时,会在 Redis 中设置一个键(代表锁)和一个过期时间(默认30秒)。同时,Redisson 会启动一个后台任务(看门狗),这个任务会定期检查锁的状态。

自动续约:

看门狗任务会每隔一段时间(默认是锁的过期时间的1/3,即10秒)检查锁的状态。如果锁仍然被持有(即客户端还在持有锁且没有释放),看门狗任务会将锁的过期时间重置为初始值(例如,再次设置为30秒)。这样,锁的过期时间不断被延长,直到客户端明确释放锁或者客户端挂掉。

释放锁:

当客户端完成业务逻辑后,会显式地调用unlock()方法释放锁。一旦锁被释放,看门狗任务会停止续约,锁在 Redis 中的键会被删除或自然过期。

看门狗机制的工作流程

获取锁:客户端请求获取锁,Redis 中创建一个键表示锁,并设置一个过期时间(例如30秒)。启动看门狗任务,定期检查锁的状态。

定期续约:看门狗任务每隔一定时间(例如10秒)检查锁的状态。如果锁仍然被持有(即客户端还在处理业务逻辑),看门狗任务会重置锁的过期时间(例如,再次设置为30秒)。

锁的释放:客户端业务逻辑完成后,调用unlock()方法释放锁。看门狗任务停止续约,锁在 Redis 中的键被删除或自然过期。

看门狗机制的优势

高可靠性:通过自动续约机制,确保锁在持有者处理完业务逻辑之前不会过期,避免了锁意外过期导致的并发问题。

自动管理:无需手动续约锁的过期时间,简化了分布式锁的使用和管理。

容错性:如果客户端在持有锁期间崩溃或断开连接,锁会在过期时间后自动释放,避免了死锁问题。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

import java.util.concurrent.TimeUnit;

public class RedissonLockExample {
public static void main(String[] args) {
RedissonClient redissonClient = RedissonConfig.createClient();
RLock lock = redissonClient.getLock("myLock");

try {
// 尝试获取锁,等待时间为100秒,锁的过期时间为10秒
if (lock.tryLock(100, 10, TimeUnit.SECONDS)) {
try {
// 业务逻辑
System.out.println("Lock acquired, executing business logic");

// 模拟长时间运行的任务
Thread.sleep(20000);

} finally {
lock.unlock();
System.out.println("Lock released");
}
} else {
System.out.println("Could not acquire lock");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
redissonClient.shutdown();
}
}
}

👌分布式锁的特点?

发表于 2025-05-05 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌分布式锁的特点?

实现,zookeeper 的实现。数据库的实现方案基本不常见,这种方式性能很低,还容易影响业务。redis 比较常见使用,主要是用 setnx 的特性来做。成功 set 进去,意味着上锁成功。失败意味着没有获取到锁,业务执行完成后,在通过 del 来释放锁。一般会配合看门狗机制,来做异常的续约处理。以上。

题目详细答案

分布式锁主要有三个特点,是我们要时刻进行注意的。

  1. 互斥性:在某一时刻,只有一个客户端可以持有锁。
  2. 容错性:即使某个持有锁的客户端崩溃或失去连接,锁也能够被其他客户端重新获取。
  3. 高可用性:锁服务需要高可用,通常需要在分布式环境中实现。

分布式锁的实现方式

基于数据库

使用数据库的SELECT … FOR UPDATE 语句或类似的行级锁机制来实现分布式锁。优点是实现简单,缺点是性能较低,依赖于数据库的高可用性。高并发情况下也会对数据库造成非常大的压力。

1
2
3
4
5
-- 获取锁
SELECT * FROM locks WHERE resource = 'resource_name' FOR UPDATE;

-- 释放锁
DELETE FROM locks WHERE resource = 'resource_name';

基于 Redis

Redis 提供了原子操作和高性能的特性,非常适合用来实现分布式锁。通常使用SETNX命令来实现。

1
2
3
4
5
6
7
8
9
10
// 获取锁
String result = jedis.set("lock_key", "lock_value", "NX", "PX", 30000);
if ("OK".equals(result)) {
// 锁获取成功
}

// 释放锁
if (lock_value.equals(jedis.get("lock_key"))) {
jedis.del("lock_key");
}

基于 Zookeeper

Zookeeper 提供了分布式协调服务,可以用来实现分布式锁。通过创建临时顺序节点来实现锁机制。

1
2
3
4
5
6
7
8
9
// 创建一个临时顺序节点
String path = zookeeper.create("/locks/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);

// 检查是否获取到锁
List<String> children = zookeeper.getChildren("/locks", false);
Collections.sort(children);
if (path.equals("/locks/" + children.get(0))) {
// 获取到锁
}

分布式锁的使用场景

  1. 分布式事务:在分布式系统中,需要确保多个节点上的操作在同一事务中执行。
  2. 资源共享:如分布式系统中的限流、分布式任务调度等场景。
  3. 数据一致性:在多个节点并发访问同一资源时,确保数据一致性。

分布式锁的常见常见问题

  1. 死锁:如果某个节点在持有锁期间崩溃或失去连接,可能会导致其他节点无法获取锁。
  2. 性能:分布式锁的实现需要考虑性能问题,尤其是在高并发场景下。
  3. 可靠性:锁服务需要高可用,通常需要在分布式环境中实现。

分布式锁的改进

  1. 锁过期时间:设置锁的过期时间,避免死锁问题。
  2. 租约机制:使用租约机制,定期续约锁,确保锁在持有期间不会被其他节点获取。
  3. 锁竞争优化:使用合适的锁竞争算法,减少锁竞争的开销。

👌JVM内存分配的指针碰撞与空闲列表是什么?

发表于 2025-05-04 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌JVM内存分配的指针碰撞与空闲列表是什么?

题目详细答案

指针碰撞(Bump-the-pointer)和空闲列表(Free-list)是两种常见的内存分配策略。

指针碰撞(Bump-the-pointer)

指针碰撞是一种高效的内存分配策略,适用于堆内存是连续且规整的情况。这种方法的基本思想是通过移动一个指针来分配内存。具体步骤如下:

  1. 内存布局:堆内存被划分为已使用的内存和空闲的内存,中间有一个指针(称为分配指针)作为分界线。
  2. 分配内存:当需要为新对象分配内存时,只需将分配指针向空闲内存方向移动一段与对象大小相等的距离。
  3. 更新指针:分配指针更新后,新的对象内存区域就被标记为已使用。

这种方法的优点是分配速度非常快,只需简单的指针移动操作。然而,它的缺点是在堆内存不规整(例如存在内存碎片)的情况下无法使用。

1
2
| 已使用内存 | 分配指针 | 空闲内存 |
|------------|----------|----------|

当分配一个对象时,分配指针向右移动:

1
2
| 已使用内存 | 已使用内存 | 分配指针 | 空闲内存 |
|------------|------------|----------|----------|

空闲列表(Free-list)

空闲列表是一种适用于堆内存不规整的情况下的内存分配策略。它通过维护一个列表来记录所有可用的空闲内存块。具体步骤如下:

  1. 空闲列表:JVM 维护一个空闲列表,记录所有可用的内存块及其大小。
  2. 查找空闲块:当需要为新对象分配内存时,JVM 会在空闲列表中查找一个足够大的内存块。
  3. 分配内存:找到合适的内存块后,将其从空闲列表中移除,并将其标记为已使用。如果内存块大于所需大小,可能会将剩余部分重新放回空闲列表中。
  4. 回收内存:当对象被垃圾回收器回收后,JVM 会将其内存块重新添加到空闲列表中。

这种方法的优点是能够更好地利用内存,适用于内存碎片较多的情况。然而,它的缺点是分配速度较慢,因为需要在空闲列表中查找合适的内存块。

假设空闲列表如下:

1
空闲列表: [块1(大小: 32), 块2(大小: 64), 块3(大小: 128)]

当需要分配一个大小为 50 的对象时,JVM 会在空闲列表中查找:

1
找到块2(大小: 64)

将块2分成两部分:

1
2
分配块2的前50个单位,剩余部分重新放回空闲列表
空闲列表: [块1(大小: 32), 块2剩余部分(大小: 14), 块3(大小: 128)]

指针碰撞(Bump-the-pointer):适用于堆内存规整的情况,分配速度快,但不适用于内存碎片较多的情况。

空闲列表(Free-list):适用于堆内存不规整的情况,能够更好地利用内存,但分配速度较慢。

这两种内存分配策略各有优缺点,JVM 会根据具体情况选择合适的策略,以优化内存分配和垃圾回收的效率。

👌jvm垃圾回收算法有哪些?

发表于 2025-05-04 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌jvm垃圾回收算法有哪些?

题目详细答案

垃圾回收算法的核心在于解决两个问题:一是确定哪些对象能够被回收(引用计数法、可达性分析法),二是如何回收这些对象。

引用计数法

引用计数法(Reference Counting)是一种内存管理技术,用于跟踪对象的引用数量。每个对象都有一个引用计数器,记录着指向该对象的引用数量。

当一个对象被引用时,引用计数器加一;当一个引用被释放时,引用计数器减一。当引用计数器为零时,表示没有任何引用指向该对象,该对象可以被释放,回收其占用的内存。

可达性分析法

可达性分析算法是JVM垃圾回收中的一种算法,它通过分析对象的引用关系,判断对象是否可达,从而决定对象是否可以被回收。

标记-清除算法

垃圾收集器首先遍历对象图,标记所有可达的对象,然后清除未标记的对象。简单直接,不需要移动对象。但是会产生内存碎片,可能导致大对象分配失败。

标记-整理算法

在标记阶段标记所有可达的对象后,压缩阶段将存活的对象移动到内存的一端,整理出连续的可用内存空间。这种方式消除了内存碎片问题。但是对象移动需要额外的时间和资源。

复制算法

将内存分为两个相等的区域,每次只使用其中一个。当这个区域使用完时,将存活的对象复制到另一个区域,然后清空当前区域。这种方式简单高效,没有内存碎片问题。缺点就是需要双倍的内存空间。

分代收集算法

根据对象的生命周期将堆内存划分为几代(通常是新生代和老年代),新生代使用复制算法,老年代使用标记-整理或标记-清除算法。优化了垃圾收集性能,因为大部分对象在新生代被收集,减少了老年代的垃圾收集频率。不过需要额外的内存管理和调优。

分区算法

将堆内存划分为多个小的独立区域(Region),每个区域可以独立进行垃圾收集。这种方式提高了内存管理的灵活性和效率,适用于大堆内存的应用。缺点是实现较复杂,需要精细的内存管理。

具体垃圾收集器使用的算法

Serial GC:使用标记-整理算法。

Parallel GC:新生代使用复制算法,老年代使用标记-整理算法。

CMS GC:新生代使用复制算法,老年代使用标记-清除算法,并发标记和清除。

G1 GC:分区算法,结合标记-整理和复制算法。

ZGC:分区算法,使用染色指针和读屏障技术,实现并发标记和压缩。

Shenandoah GC:分区算法,使用并发标记和并发压缩技术。

👌Java类初始化时机

发表于 2025-05-03 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌Java类初始化时机?

题目详细答案

主动引用

创建类的实例

当使用new关键字创建类的实例时,类会被初始化。

1
MyClass obj = new MyClass();

访问类的静态变量或静态方法

当访问类的静态变量或调用静态方法时,类会被初始化。

1
2
System.out.println(MyClass.staticVar);
MyClass.staticMethod();

反射

通过反射 API 对类进行反射调用时,类会被初始化。

1
Class.forName("com.example.MyClass");

初始化子类

当初始化一个类的子类时,父类会被初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Parent {
static {
System.out.println("Parent initialized");
}
}

class Child extends Parent {
static {
System.out.println("Child initialized");
}
}

public class Main {
public static void main(String[] args) {
Child child = new Child(); // 输出:Parent initialized, Child initialized
}
}

Java 虚拟机启动时

包含main方法的类在虚拟机启动时会被初始化。例如:

1
2
3
4
5
6
7
8
9
public class Main {
static {
System.out.println("Main class initialized");
}

public static void main(String[] args) {
System.out.println("Hello, World!");
}
}

被动引用

通过子类引用父类的静态字段

通过子类引用父类的静态字段,不会导致子类初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Parent {
static int value = 42;
}

class Child extends Parent {
static {
System.out.println("Child initialized");
}
}

public class Main {
public static void main(String[] args) {
System.out.println(Child.value); // 输出:42,不会触发 Child 的初始化
}
}

定义对象数组

定义类的对象数组不会触发类的初始化。例如:

1
MyClass[] array = newMyClass[10]; // 不会触发 MyClass 的初始化

常量引用

引用常量不会触发类的初始化,因为常量在编译阶段会存入调用类的常量池中。例如:

1
2
3
4
5
6
7
8
9
class MyClass {
static final int CONSTANT = 42;
}

public class Main {
public static void main(String[] args) {
System.out.println(MyClass.CONSTANT); // 不会触发 MyClass 的初始化
}
}

👌Java类加载器的机制是什么

发表于 2025-05-03 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌Java类加载器的机制是什么?

题目详细答案

Java 的类加载机制是 JVM 负责将类文件加载到内存中,并将其转换为Class对象的过程。它包括三个主要步骤:加载(Loading)、链接(Linking)和初始化(Initialization)。以下是详细的描述:

类加载过程

加载(Loading)

加载阶段是将类文件读入内存,并创建一个Class对象的过程。具体步骤如下:

查找和导入类的二进制数据:从不同的来源(如文件系统、网络等)获取类的字节码。

创建Class对象:将字节码转换为 JVM 能够识别的Class对象。

加载阶段可以通过系统类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用程序类加载器(Application ClassLoader)等完成。

链接(Linking)

链接阶段将类的二进制数据合并到 JVM 运行时环境中。链接阶段包括三个步骤:

验证(Verification):确保类的字节码符合 JVM 规范,保证不会破坏 JVM 的安全性。

准备(Preparation):为类的静态变量分配内存,并将其初始化为默认值。

解析(Resolution):将常量池中的符号引用转换为直接引用。

初始化(Initialization)

初始化阶段是执行类构造器方法的过程。该方法是由编译器自动收集类中的所有静态变量的赋值动作和静态代码块(static {})中的语句合并产生的。初始化阶段是类加载过程的最后一步。

类加载器(ClassLoader)

Java 的类加载器负责加载类文件。Java 中的类加载器遵循双亲委派模型(Parent Delegation Model),即类加载器在加载类时会先委托给父类加载器加载,如果父类加载器无法加载,再尝试自己加载。

双亲委派模型(Parent Delegation Model)

双亲委派模型的工作流程如下:

  1. 检查缓存:类加载器首先检查缓存中是否已经加载过该类,如果已经加载,则直接返回Class对象。
  2. 委托父类加载:如果缓存中没有,则委托父类加载器加载。
  3. 父类加载失败:如果父类加载器加载失败(抛出ClassNotFoundException),则由当前类加载器尝试加载。

这种模型的好处是避免类的重复加载,确保核心类库不会被自定义类加载器加载和覆盖。

常见的类加载器

Bootstrap ClassLoader:引导类加载器,负责加载核心类库,如rt.jar中的类。它是用原生代码实现的,不是java.lang.ClassLoader的子类。

Extension ClassLoader:扩展类加载器,负责加载JAVA_HOME/lib/ext目录中的类。

Application ClassLoader:应用程序类加载器,负责加载应用程序的类路径(classpath)中的类。

类加载器的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class MyClassLoader extends ClassLoader {

private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 将类名转换为文件路径
String fileName = classPath + name.replace('.', '/') + ".class";
// 读取类文件的字节码
byte[] classBytes = Files.readAllBytes(Paths.get(fileName));
// 将字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}

public class CustomClassLoaderDemo {
public static void main(String[] args) {
try {
// 创建自定义类加载器,指定类文件所在路径
MyClassLoader classLoader = new MyClassLoader("/path/to/classes/");
// 加载类
Class<?> clazz = classLoader.loadClass("com.example.MyClass");
// 创建类的实例
Object instance = clazz.getDeclaredConstructor().newInstance();
// 调用方法
clazz.getMethod("myMethod").invoke(instance);
} catch (Exception e) {
e.printStackTrace();
}
}
}

👌新生代空间大小的比例及如何调整??

发表于 2025-05-03 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌jvm 新生代空间大小的比例及如何调整?

题目详细答案

在 JVM 中,堆内存通常被划分为新生代(Young Generation)和老年代(Old Generation)。新生代又进一步划分为 Eden 区和两个 Survivor 区(S0 和 S1)。调整新生代空间大小的主要目的是优化垃圾收集性能,减少应用程序的停顿时间。

新生代空间的默认比例

默认情况下,HotSpot JVM 使用的比例大致如下:

新生代(Young Generation):占整个堆内存的 1/3 到 1/4 左右。

老年代(Old Generation):占整个堆内存的 2/3 到 3/4 左右。

在新生代内部,默认的比例是:

Eden 区:占新生代的 8/10(即 80%)。

每个 Survivor 区(S0 和 S1):各占新生代的 1/10(即 10%)。

新生代空间大小的调整

调整新生代和老年代的比例

-Xms和-Xmx:设置堆内存的初始大小和最大大小。

-XX:NewSize和-XX:MaxNewSize:设置新生代的初始大小和最大大小。

1
2
-XX:NewSize=512m
-XX:MaxNewSize=512m

-XX:NewRatio:设置新生代和老年代的比例。例如,-XX:NewRatio=3 表示新生代占整个堆的 1/4,老年代占 3/4。

调整 Eden 区和 Survivor 区的比例

-XX:SurvivorRatio:设置 Eden 区和 Survivor 区的比例。例如,-XX:SurvivorRatio=8表示 Eden 区占新生代的 8/10,每个 Survivor 区占 1/10。

调整 Survivor 区的数量

-XX:SurvivorRatio:默认情况下,JVM 使用两个 Survivor 区(S0 和 S1)。你可以通过调整 Survivor 区的比例来优化内存使用和垃圾收集性能。

动态调整新生代大小

-XX:+UseAdaptiveSizePolicy:启用自适应大小策略,JVM 会根据应用程序的运行情况动态调整新生代和老年代的大小。

调整策略

在调整新生代空间大小时,需要考虑以下因素:

应用程序的对象生命周期:

如果应用程序创建了大量短生命周期对象(例如 Web 应用中的请求对象),则需要较大的新生代空间,以减少 Minor GC 的频率。

如果应用程序有较多长生命周期对象,则需要较大的老年代空间,以减少 Major GC 的频率。

GC 日志分析:

启用 GC 日志(例如-Xlog:gc*或-XX:+PrintGCDetails),分析垃圾收集的频率和停顿时间,调整新生代和老年代的大小以优化性能。

性能测试:

在调整 JVM 参数后,进行性能测试,观察 GC 行为和应用程序的响应时间,进一步调整参数以达到最佳性能。

配置 demo

假设你有一个堆内存大小为 4GB 的 JVM 实例,你希望新生代占 1GB,老年代占 3GB,并且 Eden 区占新生代的 80%,每个 Survivor 区占 10%。可以使用如下参数:

1
2
3
4
5
-Xms4g -Xmx4g
-XX:NewSize=1g
-XX:MaxNewSize=1g
-XX:NewRatio=3
-XX:SurvivorRatio=8

原文: https://www.yuque.com/jingdianjichi/xyxdsi/qd6qd8z80xv9420a

👌Java双亲委派机制的作用

发表于 2025-05-02 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌Java双亲委派机制的作用?

题目详细答案

保证 Java 核心库的安全性

通过双亲委派机制,Java 核心库(如java.lang.Object等)由启动类加载器(Bootstrap ClassLoader)加载。由于启动类加载器是在 JVM 启动时由本地代码实现的,并且它加载的类路径是固定的系统核心库路径,因此可以确保这些核心类不会被篡改或替换。这样系统的安全性和稳定性得到了保障。

避免类的重复加载

双亲委派机制确保了每个类只会被加载一次。如果一个类已经被父类加载器加载过,那么子类加载器就不会再重复加载这个类。这样可以避免类的重复加载,提高类加载的效率,并减少内存消耗。

保证类加载的一致性

通过双亲委派机制,可以确保同一个类在整个 JVM 中只有一个定义。这样可以避免类的冲突和不一致问题。例如,如果应用程序和第三方库中都定义了一个相同的类名,通过双亲委派机制可以确保最终加载的是位于更高层次的类加载器中的类,从而避免冲突。

提高类加载的效率

双亲委派机制通过将类加载请求逐级向上委派,可以利用已经加载的类,提高类加载的效率。父类加载器在加载类时,如果该类已经被加载过,那么直接返回该类的引用,从而减少了重复加载的开销。

支持动态扩展

双亲委派机制允许在不同的类加载器中加载不同的类,从而支持动态扩展。例如,应用程序类加载器(Application ClassLoader)可以加载应用程序特定的类,而扩展类加载器(Extension ClassLoader)可以加载扩展库中的类,这样可以方便地进行动态扩展和模块化开发。

👌Java虚拟机进程何时结束

发表于 2025-05-02 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌Java虚拟机进程何时结束?

题目详细答案

所有非守护线程(Non-Daemon Threads)结束

JVM 进程会在所有非守护线程结束后自动退出。非守护线程是默认的线程类型,通常用于执行主要任务。守护线程(Daemon Thread)则是辅助线程,通常用于执行后台任务,例如垃圾回收。

非守护线程:主要任务线程,JVM 会等待其执行完毕。

守护线程:辅助任务线程,JVM 不会等待其执行完毕。

当所有非守护线程都结束时,JVM 会自动退出,即使还有守护线程在运行。

调用System.exit(int status)

可以通过调用System.exit(int status)方法来显式终止 JVM 进程。status参数是一个整数,通常用于表示退出状态码。

1
2
3
4
5
6
public class ExitExample {
public static void main(String[] args) {
System.out.println("Program is exiting");
System.exit(0); // 正常退出
}
}

System.exit(0):表示正常退出。非零状态码:表示异常退出。

JVM 遇到未捕获的异常或错误

如果主线程或其他非守护线程中出现未捕获的异常或错误,且没有相应的异常处理机制,JVM 进程会终止。

1
2
3
4
5
public class UncaughtExceptionExample {
public static void main(String[] args) {
throw new RuntimeException("Uncaught exception");
}
}

通过外部命令强制终止

可以使用操作系统的命令或工具强制终止 JVM 进程,例如使用kill命令(在 Unix/Linux 系统上)或任务管理器(在 Windows 系统上)。

1
2
3
4
5
# 查找 JVM 进程 ID
ps -ef | grep java

# 强制终止 JVM 进程
kill -9 <pid>

主线程结束且没有其他非守护线程

如果主线程结束且没有其他非守护线程在运行,JVM 进程也会结束。

1
2
3
4
5
public class MainThreadExample {
public static void main(String[] args) {
System.out.println("Main thread is ending");
}
}

调用Runtime.halt(int status)

Runtime.halt(int status)方法会立即终止 JVM 进程,不执行任何关闭钩子(Shutdown Hook)或finalize方法。

1
2
3
4
5
public class HaltExample {
public static void main(String[] args) {
Runtime.getRuntime().halt(0); // 立即终止 JVM
}
}

关闭钩子(Shutdown Hook)

在 JVM 进程结束前,可以注册关闭钩子来执行一些清理操作。关闭钩子是在 JVM 关闭前执行的线程。

1
2
3
4
5
6
7
8
9
public class ShutdownHookExample {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("Shutdown hook is running");
}));

System.out.println("Main thread is ending");
}
}

👌java双亲委派机制是什么

发表于 2025-05-02 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌java双亲委派机制是什么?

题目详细答案

JVM 的双亲委派机制是一种类加载机制,用于确保 Java 类加载过程的安全性和一致性。它的主要思想是:每个类加载器在加载类时,首先将请求委派给父类加载器,只有当父类加载器无法完成加载时,才由当前类加载器尝试加载类。

1725898211982-d09b6999-76ec-4ab2-9720-b89c0a8fc87f.png

双亲委派机制的工作流程

启动类加载器(Bootstrap ClassLoader):负责加载 Java 核心库(位于JAVA_HOME/lib目录下的类库,如rt.jar)。

扩展类加载器(Extension ClassLoader):负责加载 Java 扩展库(位于JAVA_HOME/lib/ext目录下的类库)。

应用程序类加载器(Application ClassLoader):负责加载应用程序类路径(classpath)上的类。

加载类的具体步骤如下:

  1. 当前类加载器收到类加载请求:当一个类加载器收到加载类的请求时,它不会立即尝试加载该类。
  2. 将请求委派给父类加载器:当前类加载器首先将加载请求委派给父类加载器。
  3. 父类加载器处理请求:

如果父类加载器存在,则父类加载器会继续将请求向上委派,直到到达启动类加载器。启动类加载器尝试加载类,如果成功,则返回类的引用。

  1. 父类加载器无法加载类:如果启动类加载器无法加载该类,加载失败返回到子类加载器。
  2. 当前类加载器尝试加载类:如果父类加载器无法加载该类,则由当前类加载器尝试加载。

通过这种机制,可以确保核心类库不会被篡改,避免了类的重复加载和类的冲突问题。

双亲委派机制的优点

安全性:通过将类加载请求逐级向上委派,可以避免核心类库被篡改或替换,确保系统安全。

避免类的重复加载:确保每个类只被加载一次,避免类的重复加载和类的冲突问题。

提高加载效率:通过委派机制,可以利用已经加载的类,提高类加载的效率。

双亲委派机制的例外

尽管双亲委派机制是 Java 类加载的标准机制,但在某些情况下,这一机制会被打破。例如:

自定义类加载器:某些自定义类加载器可能会覆盖默认的双亲委派机制,直接加载类。

OSGi 框架:OSGi 框架中,类加载机制更加复杂,可能会打破双亲委派机制。

SPI(Service Provider Interface):在某些服务提供者接口的实现中,可能需要打破双亲委派机制来加载服务实现类。

👌jvm 运行时的数据区域如何理解?

发表于 2025-05-02 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌jvm 运行时的数据区域如何理解?

题目详细答案

Java 虚拟机(JVM)在运行时将内存划分为若干不同的数据区域,每个区域都有特定的用途。

1725852039025-ee339bb3-3b55-4c11-a3a7-23d21fd77305.png

JVM 运行时数据区域

JVM 运行时数据区域主要包括以下几个部分:

方法区 (Method Area)

堆 (Heap)

Java 栈 (Java Stacks)

本地方法栈 (Native Method Stacks)

程序计数器 (Program Counter Register)

方法区 (Method Area)

方法区是所有线程共享的内存区域,用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

功能:

存储类的结构信息(如类的名称、访问修饰符、字段描述、方法描述等)。

存储运行时常量池,包括字面量和符号引用。

存储静态变量。

存储编译后的代码。

在 HotSpot JVM 中,方法区的一部分实现为永久代(PermGen),在 Java 8 及以后版本中被称为元空间(Metaspace)。

堆 (Heap)

堆是所有线程共享的内存区域,用于存储所有对象实例和数组。

功能:

动态分配对象内存。

垃圾收集器主要在堆上工作,回收不再使用的对象内存。

堆通常分为年轻代(Young Generation)和老年代(Old Generation),年轻代又进一步划分为 Eden 区和两个 Survivor 区(S0 和 S1)。

Java 栈 (Java Stacks)

每个线程都有自己的 Java 栈,栈帧(Stack Frame)在栈中按顺序存储。

功能:

存储局部变量表、操作数栈、动态链接、方法返回地址等信息。

每调用一个方法,就会创建一个新的栈帧,方法执行完毕后栈帧被销毁。

栈帧包括:

局部变量表:存储方法的局部变量,包括参数和方法内部的局部变量。

操作数栈:用于操作数的临时存储。

动态链接:指向常量池的方法引用。

方法返回地址:方法调用后的返回地址。

本地方法栈 (Native Method Stacks)

本地方法栈与 Java 栈类似,但它为本地(Native)方法服务。

功能:

存储本地方法调用的状态。

一些 JVM 使用 C 栈来支持本地方法调用。

程序计数器 (Program Counter Register)

每个线程都有自己的程序计数器,是一个很小的内存区域。

功能:

当前线程所执行的字节码的行号指示器。

如果当前执行的是本地方法,这个计数器值为空(Undefined)。

👌本地缓存与分布式缓存的区别

发表于 2025-05-02 | 更新于 2025-09-14 | 分类于 Redis
字数统计 | 阅读时长

👌本地缓存与分布式缓存的区别?

题目详细答案

概念

本地缓存:是指将数据缓存在应用程序所在的服务器或客户端的内存中。本地缓存的数据存储在应用程序的单个节点上。比如大家启动的应用里面用 hashmap 存储的数据,或者一些三方缓存,caffine,guava cache 这些都是本地缓存,特性在于缓存只存在这台机器。其他机器不知道。本地缓存重起之后,缓存就会失效。

分布式缓存:像 redis 这种就是分布式缓存,一个 redis 存储后,多个应用都可以来进行访问。同时自身支持集群模式,缓存数据可以分散存储在多个节点上。

区别对比

本地缓存 cacffine/guava cache redis
速度 位于应用程序所在的内存中,因此访问速度非常快 在网络上进行数据传输,可能会增加额外的网络开销,导致访问速度略低于本地缓存。
存储 存储在一个节点中,多个应用实例之间无法共享缓存数据。数据随应用进程的重启而丢失。 通过将数据分片存储在多个节点上,提高了缓存的容量和可扩展性。部分数据会被复制到多个节点上,以提高数据的可靠性和可用性
容量 容量受到内存大小的限制,一旦超过容量限制,可能会导致性能下降或者数据丢失。无法动态扩展。 根据需求动态添加和删除节点,以适应数据量的变化和访问负载的增加。redis 水平扩展。

扩展

当并发巨大的时候,如果 redis 的网络和 cpu 成为了瓶颈,一般可以增加一层本地缓存来进行缓冲。也就是我们说的多级缓存。

👌Java如何实现自己的类加载器

发表于 2025-05-01 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌Java如何实现自己的类加载器?

题目详细答案

在 Java 中,类加载器(ClassLoader)是负责将类文件加载到 JVM 中的组件。实现自定义类加载器可以让你控制类加载的过程,例如从非标准位置加载类文件、解密类文件等。

实现自定义类加载器的步骤

继承ClassLoader类:自定义类加载器需要继承java.lang.ClassLoader类。

重写findClass方法:重写findClass(String name)方法,这是自定义类加载器的核心方法,用于定义类的加载逻辑。

调用defineClass方法:在findClass方法中,通过defineClass方法将字节数组转换为Class对象。

从文件系统加载类 Demo

创建自定义类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;

public class MyClassLoader extends ClassLoader {

private String classPath;

public MyClassLoader(String classPath) {
this.classPath = classPath;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 将类名转换为文件路径
String fileName = classPath + name.replace('.', '/') + ".class";
// 读取类文件的字节码
byte[] classBytes = Files.readAllBytes(Paths.get(fileName));
// 将字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}

使用自定义类加载器加载类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CustomClassLoaderDemo {
public static void main(String[] args) {
try {
// 创建自定义类加载器,指定类文件所在路径
MyClassLoader classLoader = new MyClassLoader("/path/to/classes/");
// 加载类
Class<?> clazz = classLoader.loadClass("com.example.MyClass");
// 创建类的实例
Object instance = clazz.getDeclaredConstructor().newInstance();
// 调用方法
clazz.getMethod("myMethod").invoke(instance);
} catch (Exception e) {
e.printStackTrace();
}
}
}

从网络加载类 Demo

创建自定义类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;

public class NetworkClassLoader extends ClassLoader {

private String baseUrl;

public NetworkClassLoader(String baseUrl) {
this.baseUrl = baseUrl;
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
// 将类名转换为 URL
String url = baseUrl + name.replace('.', '/') + ".class";
// 从网络读取类文件的字节码
InputStream inputStream = new URL(url).openStream();
ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
int nextValue = 0;
while ((nextValue = inputStream.read()) != -1) {
byteStream.write(nextValue);
}
byte[] classBytes = byteStream.toByteArray();
// 将字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}

使用自定义类加载器加载类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class NetworkClassLoaderDemo {
public static void main(String[] args) {
try {
// 创建自定义类加载器,指定类文件所在的基 URL
NetworkClassLoader classLoader = new NetworkClassLoader("http://example.com/classes/");
// 加载类
Class<?> clazz = classLoader.loadClass("com.example.MyClass");
// 创建类的实例
Object instance = clazz.getDeclaredConstructor().newInstance();
// 调用方法
clazz.getMethod("myMethod").invoke(instance);
} catch (Exception e) {
e.printStackTrace();
}
}
}

👌JVM堆的内部结构是什么

发表于 2025-04-30 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌JVM堆的内部结构是什么

题目详细答案

JVM 堆是 Java 虚拟机用于存储对象实例和数组的内存区域。堆内存是 JVM 管理的主要内存区域之一,堆内存的管理和优化对 Java 应用程序的性能至关重要。堆内存的内部结构通常分为几个不同的区域,以便更高效地进行内存分配和垃圾回收。

新生代(Young Generation)

新生代用于存储新创建的对象。大多数对象在新生代中创建,并且很快就会被垃圾回收。新生代进一步分为三个区域:

Eden 区(Eden Space):大多数新对象首先分配在 Eden 区。当 Eden 区填满时,会触发一次轻量级的垃圾回收(Minor GC)。

幸存者区(Survivor Spaces):新生代中有两个幸存者区,称为 S0(Survivor 0)和 S1(Survivor 1)。在一次 Minor GC 之后,仍然存活的对象会从 Eden 区和当前的幸存者区复制到另一个幸存者区。两个幸存者区会在每次 GC 后交替使用。

老年代(Old Generation)

老年代用于存储生命周期较长的对象。那些在新生代经历了多次垃圾回收仍然存活的对象会被移动到老年代。老年代的垃圾回收相对较少,但每次回收的时间较长,称为 Major GC 或 Full GC。

永久代(Permanent Generation)和元空间(Metaspace)

永久代(Permanent Generation):在 JDK 8 之前,永久代用于存储类的元数据、常量池、方法信息等。永久代的大小是固定的,容易导致OutOfMemoryError错误。

元空间(Metaspace):从 JDK 8 开始,永久代被元空间取代。元空间不在 JVM 堆中,而是使用本地内存。元空间的大小可以动态调整,减少了OutOfMemoryError的风险。

堆内存的垃圾回收

JVM 使用不同的垃圾回收算法来管理堆内存。

标记-清除(Mark-Sweep):标记活动对象,然后清除未标记的对象。

标记-整理(Mark-Compact):标记活动对象,然后将它们整理到堆的一端,清理掉不活动的对象。

复制算法(Copying):将活动对象从一个区域复制到另一个区域,清理掉旧区域的所有对象。新生代垃圾回收通常使用这种算法。

分代收集(Generational Collection):基于对象的生命周期,将堆分为新生代和老年代,分别进行垃圾回收。

堆内存的配置

JVM 提供了多个参数来配置堆内存的大小和行为:

-Xms:设置堆内存的初始大小。

-Xmx:设置堆内存的最大大小。

-XX:NewSize:设置新生代的初始大小。

-XX:MaxNewSize:设置新生代的最大大小。

-XX:SurvivorRatio:设置 Eden 区与幸存者区的比例。

1
2
3
4
5
6
7
-Xms512m 
-Xmx1024m
-XX:NewSize=256m
-XX:MaxNewSize=512m
-XX:SurvivorRatio=8
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=256m

👌JVM对象的访问模式有哪些?

发表于 2025-04-30 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌JVM对象的访问模式有哪些?

题目详细答案

在 JVM 中,对象的访问模式主要指的是 JVM 如何通过引用来访问对象的具体字段和方法。不同的 JVM 实现可能会采用不同的访问模式,但主要有以下两种常见的模式:

句柄访问模式(Handle Access Mode)

在句柄访问模式下,每个对象引用指向一个句柄池中的句柄。句柄本身包含两个指针,一个指向对象实例数据(实际对象),另一个指向对象的类型数据(如类元数据)。

句柄结构

1
引用 -> 句柄 -> [对象实例数据指针 | 类型数据指针]

访问过程

  1. 引用指向句柄:对象引用首先指向句柄池中的一个句柄。
  2. 句柄指向对象:句柄包含指向实际对象实例数据的指针和类型数据的指针。
  3. 访问对象:通过句柄中的指针访问对象实例数据和类型数据。

优点

对象移动:在垃圾回收过程中,如果对象被移动,只需更新句柄中的指针,而不需要更新所有引用。

访问灵活:通过句柄可以灵活地管理对象的访问和元数据。

缺点

间接访问:每次访问对象都需要通过句柄进行间接访问,增加了访问开销。

直接指针访问模式(Direct Pointer Access Mode)

在直接指针访问模式下,每个对象引用直接指向对象实例数据。对象实例数据中包含指向类型数据的指针(通常在对象头中)。

直接指针结构

1
引用 -> 对象实例数据 -> [对象头 | 实例字段]

访问过程

  1. 引用指向对象:对象引用直接指向对象实例数据。
  2. 对象头包含类型数据指针:对象实例数据的头部包含指向类型数据的指针。
  3. 访问对象:直接通过引用访问对象实例数据和类型数据。

优点

高效访问:直接指向对象实例数据,访问速度更快。

简单结构:对象引用和对象实例数据之间的关系更简单。

缺点

对象移动:在垃圾回收过程中,如果对象被移动,所有引用都需要更新,增加了垃圾回收的复杂性。

选择与权衡

不同的 JVM 实现会根据具体的需求和优化目标选择合适的对象访问模式。现代 JVM(如 HotSpot)通常采用直接指针访问模式,因为它在访问速度和实现复杂性之间取得了较好的平衡。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ObjectAccessExample {
public static void main(String[] args) {
MyObject obj = new MyObject(); // 创建对象
obj.setValue(42); // 设置对象字段
int value = obj.getValue(); // 访问对象字段
System.out.println("Value: " + value);
}
}

class MyObject {
private int value;

public void setValue(int value) {
this.value = value;
}

public int getValue() {
return value;
}
}

MyObject类的实例通过直接指针访问模式进行访问。JVM 会直接通过引用访问对象的实例字段value。

👌JVM的直接内存是什么?

发表于 2025-04-29 | 更新于 2025-09-14 | 分类于 笔记
字数统计 | 阅读时长

👌JVM的直接内存是什么?

题目详细答案

JVM 的直接内存是指通过java.nio包中的ByteBuffer类直接分配的内存。这种内存分配方式绕过了 JVM 的堆内存管理,直接使用底层操作系统的内存分配机制。直接内存的使用可以提高 I/O 操作的性能,因为它减少了数据在 JVM 堆内存和本地操作系统内存之间的复制开销。

直接内存的特点

非堆内存:

直接内存不属于 JVM 的堆内存区域,因此不会受到堆内存的垃圾回收机制的影响。

直接内存的分配和释放由操作系统管理,而不是由 JVM 的垃圾回收器管理。

高效的 I/O 操作:

直接内存特别适合用于频繁的 I/O 操作(如文件读写、网络通信等),因为它可以减少数据在 JVM 堆内存和操作系统内存之间的复制次数。

例如,在使用java.nio中的FileChannel进行文件读写时,通过直接缓冲区(Direct Buffer)可以显著提高性能。

手动管理:

由于直接内存不受 JVM 垃圾回收机制的管理,因此需要手动释放内存。如果不及时释放,可能会导致内存泄漏和系统性能问题。

直接内存的分配

直接内存的分配通过ByteBuffer类的allocateDirect方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.nio.ByteBuffer;

public class DirectMemoryExample {
public static void main(String[] args) {
// 分配 1 MB 的直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);

// 使用直接缓冲区进行读写操作
directBuffer.put((byte) 1);
directBuffer.flip();
byte value = directBuffer.get();

System.out.println("Value: " + value);
}
}

直接内存的释放

直接内存的释放并不像堆内存那样由垃圾回收器自动管理。为了更好地控制直接内存的使用,可以使用以下方法:

  1. 显式释放:

使用第三方库(如 Netty)提供的工具类进行显式释放。例如,Netty 提供了PlatformDependent.freeDirectBuffer方法来释放直接缓冲区。

  1. 依赖垃圾回收:

虽然直接内存不受 JVM 垃圾回收器的直接管理,但ByteBuffer对象本身仍然受垃圾回收器管理。当ByteBuffer对象被垃圾回收时,其底层的直接内存也会被释放。但是,这种方式不够及时和可靠,可能会导致内存泄漏。

直接内存的配置

JVM 允许通过启动参数来配置直接内存的最大使用量:

-XX:MaxDirectMemorySize:用于设置直接内存的最大值。如果不设置,默认值为堆内存大小。

1
java -XX:MaxDirectMemorySize=256m DirectMemoryExample

原文: https://www.yuque.com/jingdianjichi/xyxdsi/foc8d6a9to7on1og

<i class="fa fa-angle-left"></i>1234…12<i class="fa fa-angle-right"></i>

239 日志
22 分类
30 标签
GitHub
© 2025 javayun
由 Hexo 强力驱动
主题 - NexT.Gemini