环己三烯的冬眠舱

天天网抑云,偶尔读点书。

0%

背景

Valine是一款基于LeanCloud的“无后端”评论系统。说是无后端,实际上LeanCloud就是他的后端。然而不幸的是,LeanCloud即将在2027年停止服务,届时本博客的留言板也将无法使用。

为了继续使用Valine,我打算自己实现一个后端服务来取代LeanCloud。正好Valine的配置文件里支持配置请求的URL,我只需要按照LeanCloud的API协议来实现服务,就能完全兼容Valine插件了。

需求分析

目前本博客的留言板没有账号系统,只有非常简单的评论功能,增删查改四大操作里,只实现了增和查。通过浏览器控制台抓包,就可以归纳出Valine工作过程中需要调用哪些接口,以及接口对应的协议。

1. 分页查询所有根评论

请求URL:GET /1.1/classes/Comment

请求参数:

回包结构:

2. 查询所有根评论数量

请求URL:GET /1.1/classes/Comment

请求参数:

回包结构:

3. 根据根评论ID查询所有子评论

请求URL:GET /1.1/cloudQuery

请求参数:

回包结构:

4. 提交评论、回复评论

请求URL:POST /1.1/classes/Comment

请求参数:

回包结构:

存储设计

我把存在LeanCloud里的评论数据导出后,得到了一个JSON串。根据JSON串中的字段名,设计MySQL数据表如下:

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
CREATE TABLE comment
(
id BIGINT(20) AUTO_INCREMENT COMMENT '自增主键',
object_id VARCHAR(32) NOT NULL DEFAULT '' COMMENT '原始对象ID',
nick VARCHAR(100) NOT NULL DEFAULT 'ANONYMOUS' COMMENT '昵称',
ip VARCHAR(45) DEFAULT '' COMMENT 'IP地址(支持IPv6)',
mail VARCHAR(100) DEFAULT '' COMMENT '邮箱',
link VARCHAR(100) DEFAULT '' COMMENT '链接',
url VARCHAR(100) DEFAULT '' COMMENT '页面URL',
comment VARCHAR(2048) NOT NULL COMMENT '评论内容(HTML格式)',
qq_avatar VARCHAR(100) DEFAULT '' COMMENT 'QQ头像链接',
ua VARCHAR(1000) DEFAULT '' COMMENT '用户代理字符串',
acl VARCHAR(500) DEFAULT '' COMMENT '访问控制列表(JSON字符串)',
inserted_at VARCHAR(100) NOT NULL DEFAULT '' COMMENT '插入时间(JSON字符串)',

-- 关联关系字段
pid VARCHAR(32) DEFAULT '' COMMENT '父评论object_id',
rid VARCHAR(32) DEFAULT '' COMMENT '根评论object_id',

-- 时间字段
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

-- 索引
PRIMARY KEY (`Id`),
UNIQUE KEY uk_object_id (object_id),
INDEX idx_created_at (created_at),
INDEX idx_updated_at (updated_at),
INDEX idx_inserted_at (inserted_at),
INDEX idx_pid (pid),
INDEX idx_rid (rid),
INDEX idx_url (url(100))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';

代码实现

详见:https://github.com/Cyclohexatriene/comment-backend-for-valine

什么是IoC

IoC全名为Inversion of Control,即控制反转。为了便于解释应用IoC带来的变化,我们先从一个没有使用IoC的例子开始说起。

在这个例子中有两个Class,一个是UserRepository,代表数据库访问层;另一个是UserService,代表服务层。在没有IoC时,代码应该这么写:

UserRepository:

为了方便(偷懒),就直接返回字符串了。

1
2
3
4
5
public class UserRepository {
public String getUser() {
return "{'id': 3200104203, 'name': 'Cyclohexatriene'}";
}
}

UserService:

1
2
3
4
5
6
7
8
9
10
11
public class UserService {
private UserRepository userRepository;

public UserService() {
this.userRepository = new UserRepository();
}

public String getUserInfo() {
return userRepository.getUser();
}
}

可以看到,在没有使用IoC的例子中,当UserService类需要依赖UserRepository类时,我们需要手动在UserService的构造函数里手动new一个UserRepository类的对象出来,然后保存在UserService里。

这就带来了一些问题:

  1. 以上只是一个简单的例子,但当依赖关系变得复杂的时候,由程序员手动进行管理会变得很麻烦。
  2. 在实际应用中,依赖于UserRepository的服务通常不会只有UserService一个。如果每个服务都给自己创建一个UserRepository对象的话就会存在浪费服务器和数据库资源的问题。

使用IoC容器就可以解决以上问题。程序员可以把对这些依赖关系的控制权交给IoC容器,让IoC容器来完成这些繁琐的任务。由于控制权由程序员交给了容器,所以才叫控制反转。从逻辑上,使用IoC后,UserService的代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
public class UserService {
private UserRepository userRepository;

public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}

public String getUserInfo() {
return userRepository.getUser();
}
}

IoC容器会调用setUserRepository方法,从外部“注入”一个实例进来,而这个实例是如何创建和配置的就不用程序员来操心了,而且共享实例也变的非常简单。因此,IoC又叫依赖注入(Dependency Injection, DI)。

Spring的IoC

在Spring的IoC容器中,我们把所有组件统称为JavaBean。配置一个组件就是配置一个Bean。在默认情况下,Bean是一个单例,Spring会在容器初始化时创建Bean,容器关闭前销毁Bean。

由于要让Spring来管理这些Bean,我们必须告诉Spring这些Bean之间的依赖关系。常用的方法有XML方式配置和Annotation方式配置。由于Annotation方式比XML方式方便太多,笔者在实际工作中也只用过Annotation方式,所以本文只介绍Annotation方式。在这种情况下,代码应该这么写:

UserRepository:

1
2
3
4
5
6
7
@Component
public class UserRepository {
public String getUser() {
System.out.println("Running: UserRepository.getUser()");
return "{'id': 3200104203, 'name': 'Cyclohexatriene'}";
}
}

UserService:

1
2
3
4
5
6
7
8
9
10
@Component
public class UserService {
@Autowired
private UserRepository userRepository;

public String getUserInfo() {
System.out.println("Running: UserService.getUserInfo()");
return userRepository.getUser();
}
}

在上面代码中,我们使用了两个注解:@Component@Autowired

@Component就是告诉Spring,这个类是一个Bean,要把这个类交给Spring进行管理。我们可以使用@Component(value = "name")来指定名称,不指定的话默认就是小写字母开头的类名(如本例中就是userRepositoryuserService)。

@Autowired就是告诉Spring,这个地方的实例需要Spring去找一个已经定义好的Bean来注入进来。至于这个Bean怎么装配,就全是Spring的事情了。

除此之外,还可以使用配置类来定义一个Bean:

1
2
3
4
5
6
7
8
@Configuration
@ComponentScan
public class AppConfig {
@Bean
public UserRepository userRepository() {
return new UserRepository();
}
}

我们可以使用@Bean注解来修饰一个方法,意思就是告诉Spring需要调用这个方法来创建一个Bean。

好了,这些差不多够用了,还有些更加个性化的用法就放着先吧。


有的没的

🤓

无内鬼

没事听点歌(Rick Astley - Never Gonna Give You Up)

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

哪些对象需要回收?

引用计数算法

在对象中添加一个引用计数器用于记录该对象的引用数量。当某对象的引用数量归零时就可以回收这个对象了。

优势:实现简单,判定效率高。简单到面试手撕代码时会考“使用引用计数算法来实现一个C++智能指针”

劣势:有大量的例外情况需要考虑,例如两个对象互相引用时引用计数器就永远不会为0,导致这两个对象永远不会被回收。

可达性分析算法

Java的内存管理系统是通过可达性分析算法来判定对象是否存活的。该算法从一组被称为“GC Roots”的根对象作为起始节点集开始,顺着引用链向下搜索,如果某对象与GC Roots之间没有任何引用链相连,说明该对象不可能再被使用,即可以被回收。

GC Roots主要包括两栈两方法:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

和由具体的垃圾收集器临时性加入的其他对象。

分代收集理论

基础假说

分代收集理论建立在两个假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。(IBM公司实测,有98%的对象熬不过第一轮GC)
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

根据以上假说,收集器设计者一般会将Java堆划分为新生代和老年代,对象在新生代区域创建,若其在若干次GC后依然存活便可晋升至老年代。每次GC可以只对新生代进行回收,也可只对老年代进行回收,以此划分为只回收新生代的“Minor GC”,只回收老年代的“Major GC”和回收整个Java堆的“Full GC”。

由于不同区域的对象有不同的特征,所以可以针对不同区域设计针对性的垃圾收集算法。

PS:Major GC只回收老年代的说法存疑,因为很多Major GC是由Minor GC触发的,所以Major GC通常跟Full GC是等价的。但是个人觉得单从分类上还是可以这么说,不用太钻牛角尖。

PPS:还有一种Mixed GC,目标是收集整个新生代和部分老年代,目前只有G1收集器会有这种行为。

跨代引用

假如现在要进行一次Minor GC,由于新生代的对象可能会被老年代中的对象引用,所以GC选择的GC Roots除了新生代本身的GC Roots外,还需要扫描整个老年代中的对象,来确保可达性分析结果的正确性,这就造成了很大的性能负担。于是引入第三条经验法则:

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

如果某个新生代对象被老年代对象引用,由于老年代对象不容易被回收,所以该新生代对象也很容易就能进入老年代,这样就不存在跨代引用了。

所以我们没必要为了少量的跨代引用去扫描整个老年代,只需要在新生代上维护一个全局的“记忆集”,记忆集会把老年代划分为若干小块,用于标记老年代的哪些块的内存存在跨代引用,此后在发生Minor GC的时候,只需要把被标记的老年代内存块中的对象加入到GC Roots里扫描就可以了。

垃圾收集算法

标记-清除算法

顾名思义,标记-清除算法有标记和清除两个阶段。首先标记出所有需要回收(或不需要回收)的对象,然后回收被标记(或未被标记)的对象。

缺点:①执行效率不稳定,如果有大量需要清除的对象就会花很多时间。②简单清除之后会产生大量不连续的内存碎片,时间久了会影响较大对象的内存分配。

标记-复制算法

标记-复制算法将内存按容量划分为大小相等的两块,每次只使用其中一块。在GC时,把还存活的对象复制到另一块,然后一次性清理掉一整块内存。

优点:①只需要复制存活对象,在存活对象较少时效率比较高。所以适合用于回收新生代。②不用考虑内存碎片问题。

缺点:浪费了一半内存。

优化:不需要按1:1的比例划分,如Appel式回收。

Appel式回收把新生代分为一块较大的Eden(伊甸园)空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor空间。GC时,将Eden和Survivor中仍然存活的对象复制到空的Survivor空间里,然后直接清理Eden和刚用完的Survivor空间。HotSpot虚拟机默认Eden和两块Survivor的空间比例是8:1:1,即每次新生代内存中可用内存空间为整个新生代内存容量的90%。这样的内存浪费就比较可以接受了。

如果Survivor空间不足以容纳一次Minor GC后存活的所有对象,那这些对象就全部直接进入老年代,也就是说此时新生代将不包括任何存活对象。如果老年代空间也不够用了,虚拟机就会触发一次Major GC以尝试释放内存。

标记-整理算法

标记-整理算法在标记完毕后,让所有存活的对象向内存空间的一端移动,然后清除掉边界以外的内存。

优点:①不存在内存浪费,也不用额外空间担保,适合用于老年代。②不会产生内存碎片。

缺点:①老年代往往会有大量对象存活,整理时需要移动这些存活对象,必须全程暂停用户程序(直到后来发明了移动时不用暂停的垃圾收集器)。

对于针对老年代的垃圾回收,标记-清除算法只需要清除少量的非存活对象,不需要长时间暂停用户程序,但会带来大量的内存碎片,采用该算法可以带来较低的时延,但同时也会有较低的吞吐量;标记-整理算法需要移动大量的存活对象,所以需要更久地暂停用户程序,但可以消除内存碎片,采用该算法会有较高的时延,但同时也会有较高的吞吐量。当然,也可以把两者结合起来,在平时多数时间使用标记-清除算法,直到内存碎片太多太碎,影响到对象分配时,再进行一次标记-整理。

一些经典的垃圾收集器

Serial收集器

顾名思义,Serial收集器是一个单线程工作的收集器。不仅是本身单线程,还得在工作时暂停所有其他用户线程。

Serial收集器在新生代采用标记-复制算法,有Serial Old收集器作为老年代收集器与之配套。

虽然要打断用户线程,但是对于内存资源受限的环境,它足够简单而高效,跟其他的花里胡哨的高级收集器相比,它的额外内存消耗最少,也没有线程交互的开销。针对少量的新生代垃圾,Serial收集器的停顿时间完全可控。所以Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

Serial Old收集器

Serial Old收集器采用标记-整理算法,是Serial收集器的老年代版本。

ParNew收集器

ParNew收集器是Serial收集器的多线程并行版本。

Parallel Scavenge收集器

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。吞吐量指的是运行用户代码的时间与处理器总消耗时间的比值。它可以通过参数-XX:MaxGCPauseMillis来设置内存回收允许的时间。垃圾收集停顿时间缩短的代价是牺牲新生代空间(收集300MB肯定比收集500MB快)和吞吐量(少量多次收集,总收集时间变长,吞吐量就降低了)换的。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。

CMS收集器

CMS收集器全名叫Concurrent Mark Sweep,顾名思义就是可以并发地完成标记,而且是标记-清除算法。它的设计目标是获取最短的回收停顿时间。整个回收过程分为四个步骤:

  1. 初始标记(仅仅只是标记一下GC Roots能直接关联到的对象,虽然需要停顿但是速度很快)
  2. 并发标记
  3. 重新标记(修正并发标记阶段用户修改的引用关系,需要停顿,但时间依然远比并发标记短)
  4. 并发清除(直接清除标记为死亡的对象,不需要移动存活对象,所以可与用户线程并发)

优点:大大降低了GC时对用户线程的停顿时间。

缺点:①会占用一部分CPU核心,在CPU核心数量不多的设备上运行时会严重影响用户进程。②在清理垃圾的同时用户线程依然在运行,还可能需要更多的内存,所以不能在内存彻底耗尽时才触发GC,需要留有余量。如果余量不足,就会出现并发失败,JVM只能改用停顿时间较长的Serial Old来重新进行GC。③会产生内存碎片,需要定期整理。

Garbage First收集器

Garbage First收集器简称G1收集器,是一款主要面向服务端应用的垃圾收集器。G1收集器将内存空间划分为若干个大小相等的Region,每个Region都可以当成新生代的Eden空间、Survivor空间或者老年代空间使用。还有一类专门用于处理大对象的Humongous Region,G1一般将这种Region视作老年代的一部分处理。通过这种设计,G1收集器可以避免每次GC都回收像整个新生代这么大的内存空间,而是选择最有性价比的Region进行回收,使得停顿时间可控。回收时,G1收集器采用标记-复制算法,整个回收过程大致可划分为四个步骤:

  1. 初始标记(需要停顿
  2. 并发标记
  3. 最终标记(需要停顿
  4. 筛选回收(把选中Region的存活对象复制到空Region里,然后清理掉整个旧Region。由于涉及存活对象的移动,所以需要停顿。)

优点:①可以指定最大停顿时间,在不同应用场景中取得吞吐量和延迟的最佳平衡。②不会产生内存碎片。

缺点:①需要占用更多额外内存来维护跨Region引用的关系。②存在和CMS收集器一样的并发失败问题。

Shenandoah收集器

Shenandoah收集器像是G1收集的下一代继承者,二者之间共享了一部分实现代码。Shenandoah收集器的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内。

Shenandoah收集器也是使用基于Region的堆内存布局,也是优先处理回收价值最大的Region,但是Shenandoah收集器支持并发的整理算法,默认不使用分代收集,且改用连接矩阵来代替G1中耗费大量内存和计算资源去维护的记忆集。

Shenandoah收集器的工作过程大致可划分为九个阶段:

  • 初始标记(需要停顿
  • 并发标记
  • 最终标记(需要停顿
  • 并发清理(清理整个Region里只有死亡对象的Region)
  • 并发回收(通过读屏障和转发指针来实现可与用户线程并发的对象移动)
  • 初始引用更新(确保并发回收阶段中的回收线程均已完成对象移动任务,需要短暂停顿
  • 并发引用更新
  • 最终引用更新(修正GC Roots中的引用,需要停顿
  • 并发清理(清理掉所有完成复制的Region)

为了实现收集器对存活对象的复制和用户线程对存活对象的访问这两件事的并发,Shenandoah回收器在对象头引入了转发指针“Brooks Pointer”。在不处于并发移动的状态下,这个转发指针指向对象自己。

但是如果不做任何保护措施的话,转发指针可能会发生并发问题。例如:

  1. 收集器线程复制了新的对象副本
  2. 用户线程更新对象的某个字段
  3. 收集器线程更新转发指针的引用值为新副本地址

如果事件2在事件1和3之间发生的话,线程2的修改就仅仅在旧对象上,无法对新对象生效。而Shenandoah收集器则是通过CAS原子操作来保证并发时对象的访问正确性的。

CAS:Compare And Swap,解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

这块内容存疑,作者没说明CAS具体是在什么操作上应用的。如果是在更新转发指针时使用CAS的话,根据我的理解,CAS的作用是可以在多个线程同时修改一个变量的时候保证并发安全,但是这里用户线程只是读取转发指针的引用值,只有收集器线程在修改转发指针的引用值。这么说来,CAS应该不能处理前面提到的那个问题。在这块内容上还有些细节作者在书中没有细说,想了一个下午也没想明白,暂且搁置。如果有人偶然看到这里,欢迎与我讨论。

优点:并发收集,可以实现很低的延迟。

缺点:需要使用读写屏障,吞吐量会受到影响。

ZGC收集器

ZGC收集器的设计目标和Shenandoah收集器是高度相似的,但实现思路却差异显著。

ZGC也是采用基于Region的堆内存布局,分为小型、中型、大型三类。

ZGC引入了“染色指针”。染色指针是一种直接将少量额外信息存储在指针上的技术。因为在64位系统中,理论上可以访问2^64B = 16EB的内存,但由于需求、性能和成本等考虑,现有的硬件架构和操作系统不会使用全部64位来寻址内存,例如Linux则使用了其中的46位。而ZGC的染色指针技术则从这46位里再抽了高4位出来存储四个标志信息,通过这四个标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入重分配集等状态。由于占用了这4位,所以ZGC能管理的内存不能超过2^42B = 4TB。染色指针使用虚拟内存映射来寻址,保证无论染色位的情况如何,都能把指针映射到相同的物理内存。

ZGC收集器的工作过程大致可划分为四个大阶段,四个大阶段都是可并发执行的,只有两个阶段中间会存在短暂的停顿,如类似G1的初始标记和最终标记。

  1. 并发标记(与G1和Shenandoah的标记类似,不过是通过染色指针来代替直接在对象上标记)
  2. 并发预备重分配(根据标记结和“特定的查询条件”统计得出本次收集过程要清理哪些Region,组成重分配集)
  3. 并发重分配(把重分配集中的存活对象复制到新的Region中)
  4. 并发重映射(修正整个堆中指向旧对象的所有引用)

ZGC重分配集和G1回收集的区别:G1收集器实现了分代回收功能,回收行为可能局限于新生代或老年代,选出的回收集也就是从局部选出的。由于分代,所以需要维护记忆集来处理跨代引用问题。ZGC收集器没有实现分代回收功能,不需要维护记忆集,省下来的时间用来进行全堆扫描了,重分配集也是从整个堆选出来的。

在并发重分配阶段,ZGC会为每个旧Region维护一个转发表,记录旧对象和新对象的转发关系。用户线程可以直接从染色指针上看出旧对象是否在重分配集里,如果用户线程发现旧对象在重分配集里,就会立即根据转发表将访问转发到新对象上(通过内存屏障实现),同时修正更新该引用的值,使其直接指向新对象,这种行为称作指针的“自愈”

由于指针可以自愈,并发重映射阶段并不需要迫切地被执行,清理旧对象的引用主要是为了减少自愈前唯一的那一次转发,以及清理完毕后可以释放转发表。因此,ZGC把并发重映射阶段的工作合并到了下一次GC的并发标记阶段里完成,两次操作只需要在同一次遍历对象图中完成即可。

有的没的

没事听点歌(Lynyrd Skynyrd - Free Bird)

引言

最近打算开始读《深入理解Java虚拟机》,作者在第一章推荐读者自己编译一份JDK,并使用CLion来阅读和调试源码。由于笔者没有原生的Linux或MacOS环境,只能使用WSL2来平替。在配置过程中踩了一些坑,遂记录一下,没准以后会有人需要。

获取源码

源码的仓库在https://hg.openjdk.java.net/jdk/jdk12 ,点击左边的browse就是源码的文件目录了。本来想直接在WSL里使用wget来下载.tar.gz文件,发现下载不动,于是选择使用Windows的浏览器直接下载.tar.gz,再从文件系统里找到WSL的目录复制进去。

然后从WSL里打开对应目录就能找到源码文件,虽然多了一个不知道有什么用的Identifier(猜测跟Windows的文件系统有关),但是并不影响源码的解压。使用tar指令解压压缩包即可获得源码。

1
tar -xzvf jdk12-06222165c35f.tar.gz

构建编译环境

不得不说Linux下配环境确实比Windows方便不少。

在开始之前建议先更新一下软件源:sudo apt-get update

安装GCC

1
sudo apt-get install build-essential

安装一些依赖库

这个表是书的作者提供的,实际操作的时候发现我的环境还缺了一个ZIPEXE(提示Could not find required tool for ZIPEXE),安装后就好了:sudo apt-get install zip

安装Bootstrap JDK

编译大版本号为N的JDK时需要用到另一个大版本号至少为N-1的已经编译好的JDK。因为OpenJDK只有部分代码使用C/C++编写,更多的是使用Java编写的,所以需要另一个编译期可用的JDK来编译这部分代码,官方称这个JDK为“Bootstrap JDK”

1
sudo apt-get install openjdk-11-jdk

进行编译

源码目录中有一个叫configure的bash文件,提供检查依赖项是否齐全和设置参数等功能。作者给出的配置指令是bash configure --enable-debug --with-jvm-variants=server,配置完毕后使用make images进行编译。但是实际上我遇到了两个问题。

没有jre目录

报错信息如下:

1
错误的路径元素 "/usr/lib/jvm/java-17-openjdk-amd64/jre/lib": 没有这种文件或目录

我顺着这个目录找下去,发现我只有/usr/lib/jvm/java-17-openjdk-amd64目录,因为从JDK11开始jre已经不是默认安装了。我们可以手动生成一个(需要切换到需要生成jre的JDK的目录):

1
./bin/jlink --module-path jmods --add-modules java.desktop --output jre

这里需要调用的应该是上面提到的Bootstrap JDK,但是我环境变量之前配过JDK17,所以这里就调用了JDK17,而不是新下载的JDK11。

调用strncpy产生的奇怪问题

报错信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=== Output from failing command(s) repeated here ===
* For target hotspot_variant-server_libjvm_objs_arguments.o:
In file included from /usr/include/string.h:495,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/utilities/globalDefinitions_gcc.hpp:35,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/utilities/globalDefinitions.hpp:32,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/utilities/align.hpp:28,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/runtime/globals.hpp:29,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/memory/allocation.hpp:28,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/classfile/classLoaderData.hpp:28,
from /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/precompiled/precompiled.hpp:34:
In function ‘char* strncpy(char*, const char*, size_t)’,
inlined from ‘static jint Arguments::parse_each_vm_init_arg(const JavaVMInitArgs*, bool*, JVMFlag::Flags)’ at /home/dzc/JVM/jdk12-06222165c35f/src/hotspot/share/runtime/arguments.cpp:2472:29:
/usr/include/x86_64-linux-gnu/bits/string_fortified.h:106:34: error: ‘char* __builtin_strncpy(char*, const char*, long unsigned int)’ output truncated before terminating nul copying as many bytes from a string as its length [-Werror=stringop-truncation]
106 | return __builtin___strncpy_chk (__dest, __src, __len, __bos (__dest));
... (rest of output omitted)

* All command lines available in /home/dzc/JVM/jdk12-06222165c35f/build/linux-x86_64-server-fastdebug/make-support/failure-logs.
=== End of repeated output ===

说实话没怎么看懂,似乎和什么字符串被截断有关,在网上查了很久都没找到有类似情况的。然后发现官方有在源码目录里附上docs,于是不抱希望地点开查看,在building.htmlTroubleshooting里找到了这样一句:

1
Hint: If caused by a warning, try configure --disable-warnings-as-errors

又想到报错信息里有这么一句:

1
cc1plus: all warnings being treated as errors

于是清除原来的配置之后重新配置,添加不把warnings视作errors的配置:

1
2
3
make clean
make dist-clean
bash configure --enable-debug --with-jvm-variants=server --disable-warnings-as-errors

之后再make images就编译成功了。进入build/配置名称/jdk/bin目录下执行./java -version可以看到版本信息:

1
2
3
openjdk version "12-internal" 2019-03-19
OpenJDK Runtime Environment (fastdebug build 12-internal+0-adhoc.dzc.jdk12-06222165c35f)
OpenJDK 64-Bit Server VM (fastdebug build 12-internal+0-adhoc.dzc.jdk12-06222165c35f, mixed mode)

在CLion中进行源码调试

由于不是原生的Linux环境,我在尝试从源码导入CMake项目的时候也卡了很久,最后按照这篇文章的流程完成了配置。

CLion连接Ubuntu

打开CLion,左侧选择WSL,再选择New Project,然后根据指引操作。选择文件目录时选择刚才的JDK源码目录(即解压之后得到的目录)。之后CLion会自动开始在WSL里下载服务端并启动。

创建自定义Build Target

点击IDE右上角的齿轮,选择Settings,左边找到Custom Build Targets,点击“+”新建一个Build Target,Toolchain选择Default,并填写Name。

然后点击Build或Clean右边的“…”,新建两个External Tools:

新建完后把make填到Build里,make clean填到Clean里。

创建自定义Run/Debug configuration

点击这个位置的Edit Configurations,并新建一个Custom Build Application。

Target选择刚才自定义的Build Target,Executable选择编译出来的JDK(刚才查看version时执行的那个程序),Program arguments可以先填一个-version测试一下。下面的build记得删掉。

至此配置完成。

测试运行、打断点

完成以上配置之后,右上角的Run和Debug应该已经变成绿色的了。

点击Run,可以发现输出了JDK的版本信息。

接下来测试断点功能。JVM的入口函数是src/java.base/share/native/libjli/java.c文件下的JavaMain(void * _args)函数。在这个函数打上断点后点击右上角的Debug,程序就会在断点处停止运行,并看到我们传入的参数信息。


有的没的

Long time no see

又有快一年没更新了。去年暑假在上海实习,回来之后彻底从e人变成了i人,没事就喜欢宅着。顺利拿了转正offer,人生的下一个阶段就是在上海当牛马了。现在又开始觉得自己没有什么可以失去的了,孩子我无敌了。上个学期一直在摆烂,最近肝完了毕业论文的初稿,趁着还算是有一点点学习的状态学一点以后工作可能用得上的东西。希望这段时间能经常更新吧。

没事听点歌(橘子海 - 夏日漱石)

Overview

本文是 Spring官方提供的教程 的中文译文。中文互联网上能查的教程质量都不怎么样(起码对于完全0基础的萌新来说),有的没头没尾的,有的细节上(比如JDK版本和Maven的pom.xml文件中的父项目版本号不兼容等)没说清楚,给萌新留下了巨大的坑。在踩了一个下午 + 半个晚上的坑之后,笔者终于下定决心去Spring官网啃官方的英文教程。虽然很不想看英文,但是看了之后发现其实也没那么难,于是决定顺手翻译成中文,顺便穿插一点自己的理解。希望能帮到后来的萌新。碎碎念结束,下面正式开始。

你将做什么

通过本教程,你将会创建一个经典的“Hello, World!”接口,所有浏览器都可以通过URL访问这个接口并得到响应。你可以在URL中设定你的名字,接口就会用更加友好的方式来会响应你。

你需要准备什么

  1. 一个你喜欢的IDE

比较热门的选择有Intellij IDEA、Spring Tools、Visual Studio Code、Eclipse和很多其他的。

  1. 一个JDK

我们推荐BellSoft Liberica JDK version 17。

译者注:IDE方面,相对而言使用IDEA的人更多。我使用的是VSCode。IDEA确实更加智能,甚至过于智能了。我更喜欢在IDE中编辑完毕后敲命令行的感觉,这让我能更好地把握我每一步都做了什么事,不至于被IDE一股脑地包办,稀里糊涂地完成了项目,总觉得这样学习效果不好。JDK方面,我的环境是Oracle的JDK20。

第一步:新建一个Spring Boot项目

start.spring.io这个网站来创建一个Web项目。在”Dependencies”里面找到并添加Spring Web这个依赖。选完之后点击”Generate”按钮,浏览器会自动开始下载一个zip文件,将其解压到你的工作目录中。

通过这个网站生成的项目会包含Spring Boot,一个不需要很多代码和配置就可以让你的应用运行Spring的框架。Spring Boot是创建Spring项目最快且最热门的途径。

第二步:添加代码

在你的IDE中打开项目,并且定位到 src/main/java/com/example/demo 文件夹下的 DemoApplication.java 这个文件,并为文件添加以下额外的方法和注解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
@RestController
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@GetMapping("/hello")
public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
return String.format("Hello %s!", name);
}
}

这就是通过Spring Boot来创建”Hello, World”这个简单的网络服务所需要的全部代码了。

我们刚刚添加的 hello() 方法可以接受一个String型的变量 name ,将其和单词 "Hello" 拼在一起后返回。所以如果你在请求中设置 name"Amy" ,程序的响应就会是 "Hello Amy!"

注解 @RestController 告诉Spring这段代码描述了一个需要能通过网络访问的端口。

注解 @GetMapping("/hello") 告诉Spring要使用上面定义的 hello() 方法来响应被发送到地址 http://localhost:8080/hello 的请求。

最后, 注解 @RequestParam 告诉Spring请求里需要有一个值,也就是上面提到的 name ,但是如果请求里没有这么一个值,那就默认采用单词 "World" 来代替。

第三步:试试看

让我们来构建并运行这个程序。打开一个命令行(或终端)并且设置工作目录为你的项目存放的目录。我们可以通过以下命令来构建并运行这个应用:

MacOS/Linux:

1
./gradlew bootRun

Windows:

1
.\gradlew.bat bootRun

译者注:上面应该是默认采用了gradle wrapper作为构建工具了。我使用的是maven wrapper,对应的命令为 .\mvnw spring-boot:run。maven在构建过程中可能需要从远程仓库下载依赖项,国内访问可能会卡,一时不行的话可以等一会儿再试试。

你应该看到类似的输出:

上面的最后两行字告诉我们Spring已经在运行了。Spring Boot内置的Apache Tomcat正在作为这个项目的Web服务器运行,并且正在监听 localhost8080 端口。打开你的浏览器,并在顶部的地址栏输入 http://localhost:8080/hello 。你应该得到这样一个很友好的响应:

小测验

如果你在上面的URL的末尾加上 ?name=Amy 应该发生什么事呢?

当然是显示 Hello, Amy! 了。


有的没的

新的征程

好久没更新博客了。跌跌撞撞结束了春招,拿到了算是相当不错的offer,然后摆了两个月。虽然和当初决心转码时追求WLB的初心有所偏离,但在这个就业形势下已经很不容易了。接下来要狠狠加班了。大概率要转Java岗,所以也在临阵磨枪学起了Spring Boot,不得不说还是Go的Gin简单。主打一个多才多艺。

没事听点歌(温和治疗 - 明明想了又想又假装没有想)

确实还是现场效果更炸一点。

1. 聊一聊智能指针

智能指针的作用是管理一个指针,避免申请的空间忘记释放导致内存泄漏。智能指针本质上是一个类模板,当超出了类的作用域时,类就会自动调用析构函数回收资源。C++里有四种智能指针:auto_ptr, unique_ptr, shared_ptr,和weak_ptr,其中auto_ptr 在C++11版本已经被废弃。

  • auto_ptr (已废弃)

auto_ptr 采用了所有权模式,后创建的指针会剥夺前面的指针的所有权。例如:

1
2
auto_ptr<int> p1(new int(1));
auto_ptr<int> p2 = p1;

p2会剥夺p1对指针的所有权,此时再访问p1就会出错。所以auto_ptr存在潜在的内存崩溃问题。

  • unique_ptr (代替auto_ptr

unique_ptr采用独占式拥有概念,保证同一时间内之有一个智能指针可以指向该对象。例如:

1
2
unique_ptr<int> p1(new int(1));
unique_ptr<int> p2 = p1;

上面的代码无法通过编译,保证在编译期就将问题排查出来。此外,unique_ptr允许被一个临时的右值赋值,例如将上述第二行代码改为p2 = unique_ptr<int>(new int(1)),则可以通过编译,因为这样不会造成悬挂指针的情况。

  • shared_ptr

shared_ptr采用共享式拥有概念,多个shared_ptr可以指向相同的对象,并且只有最后一个指向它的shared_ptr被销毁时才会释放它占有的资源。

  • weak_ptr

weak_ptr是为了避免两个shared_ptr互相引用,导致其计数永远不会归零、资源永远不会被释放的死锁现象而引入的。它不控制对象的生命周期,不会改变计数器。weak_ptr没有重载*->运算符,所以不能直接访问和修改引用的对象(可以通过lock()函数将其转化为shared_ptr然后再访问),但可以访问对象的引用数量等信息,它更像是一个shared_ptr的监控者。

2. 能不能自己实现一个shared_ptr

自己封装一个类模板就行。需要实现构造函数和析构函数、拷贝构造函数和拷贝赋值函数,还有重载一些指针的操作符。

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class shared_cnt {
//计数器类
private:
int cnt;
public:
shared_cnt() :cnt(1) {}
void add() {
cnt++;
}
void reduce() {
cnt--;
}
int getCnt() {
return cnt;
}
};

template <typename T>
class my_shared_ptr {
private:
T* ptr;
shared_cnt* cb;
public:
// 构造函数和析构函数
my_shared_ptr(T* p = NULL) {
ptr = p;
cb = new shared_cnt();
}
~my_shared_ptr() {
cb->reduce();
if (cb->getCnt() == 0) {
delete ptr;
delete cb;
cout << "deleted." << endl;
}
}

// 指针操作
T& operator*() const {
return *ptr;
}
T* operator->() const {
return ptr; // p->m被解释为(p.operator->())->m,所以返回值应该是一个指针。
}
operator bool() const {
return ptr;
}
T& operator[](int a) {
return ptr[a];
}
const T& operator[](int a) const {
return ptr[a];
}

// 拷贝构造函数
my_shared_ptr(const my_shared_ptr& other) {
ptr = other.ptr;
cb = other.cb;
cb->add();
}
// 赋值函数
my_shared_ptr& operator=(const my_shared_ptr& other) {
cb->reduce();
if (cb->getCnt() == 0) {
delete ptr;
delete cb;
cout << "old ptr has been deleted." << endl;
}
ptr = other.ptr;
cb = other.cb;
cb->add();
return *this;
}

int getCnt() {
return cb->getCnt();
}

};

3. 虚函数是什么原理?

每个包含虚函数的类(或者继承了包含虚函数的基类)都有一个自己的虚函数表,这个表是一个编译时就确定的静态数组。虚函数表包含了指向每个虚函数的函数指针。而编译器会在基类中定义一个隐藏的指针vptr,这是一个指向虚函数表的指针,在类对象创建的时候vptr 会设置成指向类的虚函数表。所以含有虚函数的类会多分配一个指针的大小。如果子类重写了基类的虚函数,就会将虚函数表中的函数指针覆盖为自己重写的函数以供调用。

如以下例子所示,基类Basefunction1function2两个函数,子类D1D2分别重写了这两个函数。所以在D1的对象的虚函数表中,function1会指向自己重写的新函数,而function2会指向基类的function2D2同理。

还有一个很巧妙的事情。看下面的代码:

1
2
D1 d1;
Base *dPtr = &d1;

因为 dPtrBase 类型指针,它只指向 d1 对象的 Base 类型部分(即,指向 d1 对象中的 Base 子对象),而 vptr 也在 Base 类型部分。所以 dPtr 可以访问 Base 类型部分中的 vptr 。同时,这里注意, dPtr->__vptr 指向的是 D1 的虚拟函数表,这是在 d1 初始化时就确定的。所以结果,尽管 dPtrBase 类型指针,但它能够访问 D1 的虚函数表。

所以当调用dPtr->function1()时,发生了这些事情:

  1. 程序识别到function1()是一个虚函数。
  2. 程序使用 dPtr->__vptr 获取到了 D1 的虚函数表。
  3. 它在 D1 的虚函数表中寻找可以调用的 function1() 版本,这里是 D1::function1()
  4. 所以这次调用实际调用的就是D1::function1()

4. 含有虚函数的类的size问题?

  • 如果类里只有一个虚函数,那这个类有多大?

只要有虚函数就会创建一个虚表指针,一个指针是4字节,所以这个类就是4字节。

  • 那要是有两个虚函数呢?

那也是只有一个虚表指针,还是4字节。

  • 扩展:类的内存布局

在没有继承的情况下,类的内存布局会根据声明顺序依次排布,且会有对齐现象,默认对齐大小为类内最大的基础类型大小。成员函数存在代码区,不占类的内存。静态变量存在全局存储区,也不占类的内存。例如:

1
2
3
4
5
6
class A{
short a;
int b;
static int c;
void func1() {}
};

此时A对象的short a本来占两个字节,为了与最大的基础类型int对齐,接下来的2字节将会跳过,然后是int b的4字节,一共是8字节。如果将func1设置成虚函数,则会在类的头部出现一个虚表指针,那么A就是12字节。

虚函数表会单独对齐,例如,将b改为double类型,类内存的布局就是:4字节虚表指针,4字节对齐,2字节short a,6字节对齐,然后是8字节的double b,一共是24字节。

  • 一个空类是多大?

一个空类理论上来说是0字节,但是编译器会为其加入1字节,这是为了它的每个对象都有独一无二的地址。例如对象a的地址是0x00000000,那对象b的地址就不可能再是0x00000000,起码也是0x00000001,这就意味着对象a占据了1字节的空间。 当另一个类继承了这个空类时,这个空类的内存就变回0字节了。

5. 虚函数和纯虚函数的区别?

虚函数由基类定义一个默认的函数,子类在继承时可以重写一个自己专属版本,也可以直接采用基类的默认函数;而纯虚函数则由基类给出声明,子类必须自行完成定义。含有纯虚函数的类被称作抽象类,无法直接创建实例,需要被继承后创建子类的实例。

6. 你的项目里用到了大量的锁,有什么优化方式吗?

我的回答:我目前是获得一次锁只从队列中获取一个任务,可以设计成获得一次锁就从队列里获取若干个任务一起处理,这样可以均摊锁的成本。

7. 有没有可能完全不用锁?

我的回答:可以加一层代理,由一个线程统一对任务队列进行管理,接到任务时主动指定某个线程并将其唤醒处理,不使用锁的机制抢任务处理。

8. 你的项目实现了注册和登录功能,如何保证(传输的过程和存放)的数据安全?

我的回答:不是很了解,可以加密,传输密文,或改用HTTPS。

9. 那你知道HTTPS的原理吗?

有点长,回头再来学。

10. 你知道内存分配的方式吗?比如malloc/jemalloc之类的

我的回答:只知道malloc,但知道操作系统级别的内存分配方式,面试官让我展开说说,我就说了一些操作系统的内存管理方式,比如说连续静态分配、动态分区、分页式之类的。追问如何减少碎片

  • malloc(size_t size):C/C++标准库的函数,分配size字节的内存,返回所分配区域的第一个字节的指针,如果内存不够就返回NULL。不会对空间进行初始化。

  • calloc(size_t num, size_t size):为一个大小是num的数组分配内存,每个元素的大小是size,返回指向所分配区域的第一个字节的指针。如果内存不够就返回NULL。每个元素会被初始化为0。

  • alloca(size_t size):在栈上申请内存,不需要free函数释放。很快,适合小的分配。但是可移植性差,不推荐使用。

  • tcmalloc:google的内存分配管理模块

  • jemalloc:BSD的内存分配管理模块

11. HTTP报文由哪些结构组成?消息报头都有哪些字段?

请求报文:

  1. 请求行(请求方法、URL、HTTP版本)

  2. 请求头部(客户端信息、目标Host、需求语言等信息)

  3. 空行

  4. 请求数据(POST的信息)

响应报文:

  1. 状态行(HTTP版本、状态码)

  2. 消息报头(必备Content-Type和Content-Length,还有服务器信息、文件最后更新时间、压缩算法等)

  3. 空行

  4. 响应正文(HTML代码、图片数据等)

12. HTTP状态码有哪些?

  1. 信息响应,以1开头。例如100 Continue,POST方法可能会将请求头和请求数据分成两个数据包进行发送,服务器收到请求头时就会先响应一个100 Continue表示请求头没有问题,可以继续发请求数据。

  2. 成功响应,以2开头。例如200 OK

  3. 重定向消息,以3开头。例如301 Moved Permanently,表示请求资源的URL已被永久更改,会在响应中给出新的URL。还有307 Temporary Redirect308 Permanent Redirect等。

  4. 客户端错误响应,以4开头,例如400 Bad Request403 Forbidden404 Not Found

  5. 服务端错误响应,以5开头,例如502 Bad Gateway503 Service Unavailable等。

13. Linux环境下,程序出错,错误信息会记录在什么文件里?

我答了个errno,面试官指出这是系统调用的错误信息;我说那我不知道了,我只见过stdout的错误信息,比如段错误之类的;面试官追问,那除了段错误还有什么呢,我回忆了一下说不知道了(其实还有算术错误,比如分母为0、无对应操作符、返回值类型有误、未定义变量/函数、类型溢出等等)。

会存放在core文件里,可以使用gdb打开core文件(指令为gdb [exec file]] [core file],例如 gdb ./a.o ./core)。指定core文件进入gdb后,gdb会自动显示如下的错误信息,包括错误的类型、出错的线程、代码所在行以及具体是哪句代码。

1
2
3
4
Core was generated by `./a.o'.
Program terminated with signal SIGFPE, Arithmetic exception.
#0 0x000055f9f4f5db7b in main () at ./a.cpp:24
24 cout << 1/a << endl;

也可以使用wherebt指令查看,但不会显示出具体的代码。

1
2
3
4
(gdb) where
#0 0x000055f9f4f5db7b in main () at ./a.cpp:24
(gdb) bt
#0 0x000055f9f4f5db7b in main () at ./a.cpp:24

备注:

  1. 使用ulimit -c查看core文件的最大大小,如果是0则不会生成core文件。如果指定文件大小,但生成的信息超过此大小,就会被裁剪而生成不完整的core文件,在调试此文件时gdb会提示错误。一般直接设置成ulimit -c unlimited就可以了。
  2. 如果要查看具体出错在哪行,使用g++编译时要加上-g获取调试信息,否则只会显示在哪个线程出错。
  3. core文件的生成路径记录在/proc/sys/kernel/core_pattern这个文件里。

14. 手撕代码

给定一个正整数N,打印出比N大的最小的“非重复数”(相邻数位不同,例如1120是重复数,1210是非重复数)。

测试用例:19901, 9901, 1120, 9

思路:找到最高位的相邻数位相同的位置,对其进行处理(变得更大,必要时进位),剩下的用010101…填充即可。

问题:如果使用测试用例219901,在一次进位后会变成22开头的数字,需要第二次进位。试图以循环方式处理,被面试官称为“缝缝补补”,很不优雅。

解决方案:从右往左遍历,每扫到重复的就进行一次增加处理,直到全部相邻数位都不重复

进阶:不使用string。

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
int main() {
unsigned int num;
unsigned int sin = 0;
cin >> num;
num++;


unsigned int newNum = num;
unsigned int newSin = 0;
while (num) {
if ((num - num / 10) % 10 == 0) {
do {
num++;
} while ((num - num / 10) % 10 == 0);
newNum = num;
newSin = sin;
}
num /= 10;
sin++;
}

cout << newNum;
for (int i = 0; i < newSin; i++) {
cout << i % 2;
}
cout << endl;
}

DLC.1 对象切片

对象切片:当一个函数的参数是按值传递的,且传递的对象类型是基类。当调用该函数时,传入派生类对象时,会自动向上转型,将对象转换成基类对象,并删除派生类中新增的任何成员。例如:

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
class A {
public:
int a;
A(int v):a(v){}
virtual void print() {
cout << "this is A" << endl;
}
};

class B : public A {
public:
int b;
B(int v1, int v2):A(v1), b(v2){}
void print() {
cout << "this is B" << endl;
}
};

void p(A a) {
a.print();
}

int main() {

B b(114, 514);
b.print();

A a = b;
A a1(114);
a.print();
a1.print();

cout << "addr of a: " << &a << endl;
cout << "addr of b: " << &b << endl;

p(a);
p(b);
p(a1);

}

上面这段代码的输出是

1
2
3
4
5
6
7
8
this is B
this is A
this is A
addr of a: 010FF778
addr of b: 010FF788
this is A
this is A
this is A

由此可见,A a = b这句代码中发生了对象切片。因为这里创建了一个新的A类对象,自然就创建了新的A类的虚表指针,指向基类的函数。但是注意,如果传递指针,则不会发生对象切片,因为没有调用类的构造函数,只是一个指针的复制或拷贝过程,例如:

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
class A {
public:
int a;
A(int v):a(v){}
virtual void print() {
cout << "this is A" << endl;
}
};

class B : public A {
public:
int b;
B(int v1, int v2):A(v1), b(v2){}
void print() {
cout << "this is B" << endl;
}
};

void p(A* a) {
a->print();
}

int main() {

B* b = new B(114, 514);
b->print();

A* a = b;
A* a1 = new A(114);
a->print();
a1->print();

cout << "addr of a: " << a << endl;
cout << "addr of b: " << b << endl;

p(a);
p(b);
p(a1);

}

上面的代码输出是:

1
2
3
4
5
6
7
8
this is B
this is B
this is A
addr of a: 00D809E8
addr of b: 00D809E8
this is B
this is B
this is A

只有新创建的a1对象调用了基类的函数。

DLC.2 静态联编和动态联编

联编:将源代码中的函数调用解释为执行特定的函数代码块的过程称为函数名联编。意思就是,同一个名称的函数有多种,联编就是把调用和具体的实现进行链接映射的操作。

联编中,C++编译器在编译过程中完成的编译叫做静态联编

但是重载、重写、虚函数使得静态联编变得困难。因为编译器不知道用户将选择哪种类型的对象,执行具体哪一块代码。所以,编译器必须生成能够在程序运行时选择正确的虚函数的代码,这个过程被称为动态联编

编译器对非虚方法使用静态联编,对虚方法使用动态联编。

例如:

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
class A {
public:
int a;
A(int v) :a(v) {}
virtual void print() {
cout << "this is A" << endl;
}
void f() {
cout << "function A" << endl;
}
};

class B : public A {
public:
int b;
B(int v1, int v2) :A(v1), b(v2) {}
void print() {
cout << "this is B" << endl;
}
void f() {
cout << "function B" << endl;
}
};

void p(A* a) {
a->print();
}

void f(A* a) {
a->f();
}

int main() {

A* a = new A(114514);
B* b = new B(114, 514);


p(a);
p(b);

f(a);
f(b);

}

上述代码运行的结果是:

1
2
3
4
this is A
this is B
function A
function A

可以看到,编译器对虚函数进行了动态联编,分别调用了基类和子类的函数;而对非虚函数进行了静态联编,总是调用基类的函数。

DLC.3 公有、保护、私有继承

继承时,我们要选择继承方式,例如:class B : public A就是B以公有继承的方式继承了A。

而这个public修饰的是从类A继承来的对象在B里的新权限。例如:

1
2
3
4
5
6
7
8
class A {
private:
int a;
protected:
int b;
public:
int c;
};

如果类B对其进行公有继承a为A类私有,无法继承;b被继承为保护对象;c被继承为公有对象。

如果类B对其进行保护继承a为A类私有,无法继承;b被继承为保护对象;c被继承为保护对象。

如果类B对其进行私有继承a为A类私有,无法继承;b被继承为私有对象;c被继承为私有对象。

规律:以最严格的权限为准。

DLC.4 类和结构体的区别

在C的时代,struct不能包含函数。

但是在C++时代,struct不仅可以包含函数,还可以包含虚函数、可以继承和多态、可以使用模板,甚至跟class可以互相继承,几乎和class没有区别。C++保留struct的一大原因(甚至可能是唯一原因)就是为了兼容C。

但是区别还是有的。

  1. struct的成员默认是public;class的成员默认是private。
  2. struct默认是公有继承;class默认是私有继承。默认继承方式以子类为准。
  3. struct不能定义模板参数,class可以(就像typename)。

还有一个细节共同点:struct和class如果定义了构造函数,就都不能用大括号初始化(A a{5};),如果没定义构造函数,且成员变量都是公有的,就可以使用大括号初始化。


reference

牛客网 C++面经

C++ | 虚函数表及虚函数执行原理详解

C++手把手带你实现一个智能指针

浅析C++类的内存布局

C/C++内存分配函数差异及高效率内存分配库总结

HTTP 响应状态码

GET 和 POST 的区别?

使用GDB(一):分析core.xxx文件常用方法

c++中的对象切片

C++ 动态联编和静态联编

C++公有继承,保护继承,私有继承的区别

【C++】struct和class的区别

Gin是基于Go开发的Web微框架,相当简洁好用。

Download Gin

1
go get -u -v github.com/gin-gonic/gin

Usage

需要 import "github.com/gin-gonic/gin"

创建

  • gin.Default()

生成一个gin实例。

接收请求

HTTP请求中有GET,POST,PUT,PATCH,DELETEOPTIONS等方法,还有一个Any可以匹配所有方法。

  • GET(path, func)

声明一个“路由”(即被请求的路径path),当客户端(浏览器)使用HTTP的GET方法向服务器请求位于path的页面时,触发func所定义的函数进行处理。这个func只有一个参数,即gin.Context类型的指针,这个指针指向的地址空间储存了一些对传来的HTTP报文解析后的信息,解析过程是gin封装好的。POST(path, func) 等也同理。

此外,Gin还支持分组路由功能。例如有一组路由均为/a打头,则可以写为:

1
2
3
4
5
a := r.Group("/a")
{
v1.GET("/b", defaultHandler) // GET /a/b
v1.GET("/c", defaultHandler) // GET /a/c
}

数据收集

有时请求的URL中会承载一定的信息,这部分信息可以使用Param()Query()获取。

  • Param(string)

如果要使用Param()方法,则声明的URL中需要包含对应的“占位符”(不清楚学名,笔者自己这么称呼的),用冒号来表示。例如:

1
2
3
4
r.GET("/user/:name", func(c *gin.Context) {
name := c.Param("name")
c.String(http.StatusOK, "hello, %s", name)
})

此时,如果浏览器向服务器请求"/user/dzc",则服务端会返回字符串"hello, dzc"

  • Query(string)

如果要使用Query()方法,则实际请求的URL中需要包含问号?,将需要发送到服务端的信息以键值对的形式放在问号后面,键值对之间使用&分隔。例如:

1
2
3
4
5
6
r.GET("/welcome", func(c *gin.Context) {
first := c.Query("first")
last := c.Query("last")
out := "hello, " + first + last
c.String(http.StatusOK, out)
})

此时,如果浏览器向服务器请求"/welcome?first=d&last=zc",则服务器回返回字符串hello, dzc

另一些时候,浏览器的数据会以POST方法发送给服务器,这部分数据可以使用PostForm()来解析。

  • PostForm(string) & DefaultPostForm(string, string)

用POST方法提交的表单数据也是以键值对的形式表示的,可以使用PostForm()将其解析出来,而该方法的另一个变种DefaultPostForm()则允许使用者在未解析到所需字段时设置一个默认结果,例如:

1
2
3
4
5
6
7
8
9
r.POST("/register", func(c *gin.Context) {
login := c.PostForm("login")
passwd := c.DefaultPostForm("pass","defaultpasswd")
if login == "3200104203" && passwd == "4203" {
c.String(http.StatusOK, "Login successful.")
} else {
c.String(http.StatusOK, "Wrong password.")
}
})

返回数据

Gin支持返回多种类型的数据,如字符串、JSON、HTML页面等。

  • String(int, string)

String()用于返回一个字符串,第一个参数是返回的HTTP状态码,第二个参数是要返回的字符串内容。例如:c.String(http.StatusOK, "hello world")

  • JSON(int, gin.H)

JSON()用于返回一个JSON对象,第一个参数是返回的HTTP状态码,第二个参数是要返回的JSON内容。gin.H类型实际上就是map[string]interface{},其中空接口可以代表任何类型(所有类型均视为实现了一个空接口),所以gin.H类的实例相当于一个JSON对象。

  • HTML(int, string, gin.H)

HTML()用于返回一个HTML页面,第一个参数是返回的HYTTP状态码,第二个参数是要返回的页面的路径,第三个参数是需要嵌入到HTML中的数据(如果HTML中出现{{.msg}},即Gin的模板语法,则会用"this is a message from server."代替)。在使用这个函数之前,需要用LoadHTMLGlob()LoadHTMLFiles()将HTML页面文件从硬盘加载进内存中,其中前者可以一次加载整个目录下的所有文件,而后者则单独加载某一个文件。例如:

1
2
3
4
5
6
r.LoadHTMLFiles("root/index.html") // 或 r.LoadHTMLGlob("root/*")
r.GET("/reg", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"msg": "this is a message from server.",
})
})

中间件

中间件,顾名思义就是在浏览器和服务器中间的一层东西。而这层东西可以对浏览器发来的请求进行拦截并进行一些预处理,例如权限验证等。此外,中间件还可以在服务器完成处理后、向客户端发送响应前进行一些处理(如添加统一的响应头等)。Gin有内置一些中间件,如默认使用的Logger()Recovery()

  • 全局使用中间件Use(func)

Use()方法用于全局使用中间件。例如:r.Use(gin.Recovery())

  • 路由分组使用中间件

在创建路由分组时可以添加该路由分组使用的中间件。

例如:user := router.Group("user",gin.Recovery())

  • 单个路由使用中间件

在创建单个路由时也可以添加该路由使用的中间件。

例如:r.GET("/",gin.Recovery(),DefaultHandler)

  • 自定义中间件

Gin规范了自定义中间件的方式:

1
2
3
func MyMiddleware(c *gin.Context){

}

在自定义中间件时,如果需要与服务端进行数据传递的话,可以使用Set()Get()方法。

  • Set(string, interface{})

使用Set()时,相当于为gin.Context设置了一个键值对。其中键必须是string类型的,而值可以是任意类型的。

  • Get(string)

使用Get()可以将之前Set()的值读取出来,其返回两个值,第一个是键对应的值,第二个是该键是否存在,用布尔类型表示。

Next()方法可以划分中间件的前置和后置功能。Next()调用前的代码将在请求到达服务端之前进行,而Next()调用后的代码则会在服务端处理完毕后、正式向客户端发送响应前运行。

Abort()方法可以拦截请求/响应。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
MyMiddlewareForAuth := func(c *gin.Context) {
login := c.PostForm("login")
passwd := c.PostForm("pass")
if login == "3200104203" && passwd == "4203" {
c.Set("Authres", 1)
} else {
c.Set("Authres", 0)
c.Next()
c.String(http.StatusOK, "Wrong password.")
}
}

r.POST("/register", MyMiddlewareForAuth, func(c *gin.Context) {
if val, exists := c.Get("Authres"); exists && val == 1 {
c.String(http.StatusOK, "Login successful.")
} else {
c.String(http.StatusOK, "hello?\n")
}
})

Reference

https://geektutu.com/post/quick-go-gin.html

https://zhuanlan.zhihu.com/p/151818857

https://cloud.tencent.com/developer/article/1585029

有的没的

没事听点歌(Billie Eilish - bad guy)

好久不见,刚好一个月没更新了。一个月里还发生了蛮多事的,单一个防疫政策就已经大变天了。希望能看到这句话的朋友们都能保护好自己,能晚不早,能阴不阳。


概述

在线离线 可以简单地理解为对于所有的操作是否需要读入完毕

在线算法的要求是,不用先知道所有的操作(如查询、修改等),一边读入一边执行,所有操作之间的独立性比较高。

而离线算法则相反,要求必须先知道所有的操作,再执行操作。这样的话,我们就有机会合理安排操作顺序,以更高的效率完成所有操作。


例题

LeetCode 1697. 检查边长度限制的路径是否存在

给你一个 n 个点组成的无向图边集 edgeList ,其中 edgeList[i] = [ui, vi, disi] 表示点 ui 和点 vi 之间有一条长度为 disi 的边。请注意,两个点之间可能有 超过一条边

给你一个查询数组 queries ,其中 queries[j] = [pj, qj, limitj] ,你的任务是对于每个查询 queries[j] ,判断是否存在从 pjqj 的路径,且这条路径上的每一条边都 严格小于 limitj

请你返回一个 布尔数组 answer ,其中 answer.length == queries.length ,当 queries[j] 的查询结果为 true 时, answerj 个值为 true ,否则为 false

示例 1:

1
2
3
4
5
输入:n = 3, edgeList = [[0,1,2],[1,2,4],[2,0,8],[1,0,16]], queries = [[0,1,2],[0,2,5]]
输出:[false,true]
解释:上图为给定的输入数据。注意到 01 之间有两条重边,分别为 216
对于第一个查询,01 之间没有小于 2 的边,所以我们返回 false
对于第二个查询,有一条路径(0 -> 1 -> 2)两条边都小于 5 ,所以这个查询我们返回 true

示例 2:

1
2
3
输入:n = 5, edgeList = [[0,1,10],[1,2,5],[2,3,9],[3,4,13]], queries = [[0,4,14],[1,4,13]]
输出:[true,false]
解释:上图为给定数据。

题解

我的第一反应是在线查询的思路,就是把每个 query 都当成独立的,然后再写一个函数判断是否可行,而这个函数的算法可能会采用BFS。后来觉得容易TLE,瞄了眼题解发现要用并查集。然后我就闷头写了个并查集,查询时,将边长度小于当前 query 限制距离的边的两个端点 union 起来,发现还是TLE。(有关并查集的内容可以查阅 这篇文章 。)

然后仔细研究了题解,发现了离线查询这东西。因为题目的查询操作都是给定的,所以我们可以根据每个 query的距离限制对 queries 数组进行升序排序,同时也根据边的长度对 edgeList 进行升序排序,再按照新的queries 顺序进行操作。这样,限制距离更小的 query 操作起来就是限制距离更大的 query 的子集,跟在线查询比都不知道省到哪里去了。

代码

C++

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
class Solution {
private:
vector<int> vec;
int find(int a) {
if (vec[a] == a) return a;
else {
int root = find(vec[a]);
vec[a] = root;
return root;
}
}
void uni(int a, int b) {
vec[find(a)] = find(b);
}
public:
vector<bool> distanceLimitedPathsExist(int n, vector<vector<int>>& edgeList, vector<vector<int>>& queries) {
vec = vector<int>(n, 0);
iota(vec.begin(), vec.end(), 0);

vector<bool> res(queries.size(), false);

sort(edgeList.begin(), edgeList.end(), [](vector<int>& e1, vector<int>& e2) {
return e1[2] < e2[2];
});

vector<int> seq(queries.size());
iota(seq.begin(), seq.end(), 0);
sort(seq.begin(), seq.end(), [&queries](int i1, int i2) {
return queries[i1][2] < queries[i2][2];
});

int idx = 0;
for (int i : seq) {
while (idx < edgeList.size() && edgeList[idx][2] < queries[i][2]) {
uni(edgeList[idx][0], edgeList[idx][1]);
idx++;
}
res[i] = (find(queries[i][0]) == find(queries[i][1]));
}
return res;
}
};

Go

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
func distanceLimitedPathsExist(n int, edgeList [][]int, queries [][]int) []bool {
vec := make([]int,n)

for i := range vec {
vec[i] = i
}
var find func(int) int
find = func(a int) int {
if vec[a] != a {
vec[a] = find(vec[a])
}
return vec[a]
}

union := func(a,b int) {
vec[find(a)] = find(b)
}

for i := range queries {
queries[i] = append(queries[i], i)
}

sort.Slice(edgeList,func(i,j int) bool {
return edgeList[i][2] < edgeList[j][2]
})
sort.Slice(queries,func(i,j int) bool {
return queries[i][2] < queries[j][2]
})

res := make([]bool,len(queries))
var idx int = 0
for _,query := range queries {
for idx < len(edgeList) && edgeList[idx][2] < query[2] {
union(edgeList[idx][0],edgeList[idx][1])
idx++
}
res[query[3]] = find(query[0]) == find(query[1])
}

return res
}

有的没的

入坑Golang

投实习的时候发现好多后端都要求用Go开发,于是决定入坑。语法上过了一遍菜鸟教程,然后力扣上题也刷起来了,中等以下的题都是直接用Go写,今天的Hard用C++写了一遍之后再用Go写了一遍。Go的语法就像是C/C++和Python的融合怪,取了二者之精华,还增加了一些C/C++并没有但是很实用很酷炫的特性(比如for … range语法可以同时迭代下标和值),我很中意。但也有很多有些别扭的地方,比如不支持set ,只有 map ,虽然本质上差不多,也可以用 map 实现 set 的功能,但是用起来就是很麻烦。还没内置 queuestack 之类的数据结构,甚至连 minmax 这种简单而又常用的函数都需要自己手搓。STL真是绝绝子好用到跺jiojio。

没事听点歌(Coldplay - Viva La Vida)

概述

折半搜索(meet-in-the-middle),虽然听起来有点像二分查找,但是其实是两种不同的算法。一般针对数组元素组合的暴力搜索,复杂度会来到 O(2^n),稍微有点数据量就容易TLE。而折半搜索则是一种取巧的方式,一次只爆搜一半的数据,爆搜两次,然后将两次爆搜的结果组合起来,就可以提升很多效率。不过复杂度依然是指数级别的。


例题

LeetCode 805. 数组的均值分割

给定你一个整数数组 nums

我们要将 nums 数组中的每个元素移动到 A 数组 或者 B 数组中,使得 A 数组和 B 数组不为空,并且 average(A) == average(B)

如果可以完成则返回true , 否则返回 false

注意:对于数组 arr , average(arr)arr 的所有元素的和除以 arr 长度。

提示:1 <= nums.length <= 30

示例 1:

1
2
3
输入: nums = [1,2,3,4,5,6,7,8]
输出: true
解释: 我们可以将数组分割为 [1,4,5,8] 和 [2,3,6,7], 他们的平均值都是4.5

示例 2:

1
2
输入: nums = [3,1]
输出: false

题解

分割后的两个数组的平均数相同,那么把它们和回去,平均数肯定不变,所以两个数组的平均数等于原数组的平均数。所以我们只需要判断原数组是否存在一个平均数和原数组的平均数相等的非空子数组就行了。

本题数组最长长度会有30,如果直接二进制暴力枚举的话,需要枚举 2^30 次,指定会TLE。所以就可以使用折半搜索,先搜索前半部分,然后把搜索结果存进哈希表里,然后再搜剩下一半。在搜剩下一半时,每枚举到一种组合,就再枚举它和前半部分组合后的组合的长度和这个长度对应的理论元素总和,然后去哈希表中查找这个理论总和是否真实存在,如果存在就说明我们找到了可行解,返回 true 即可。这样我们最多只需要枚举两个 2^15 就行,数量级一下子就下来了。

代码:

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
class Solution {
public:
bool splitArraySameAverage(vector<int>& nums) {
int n = nums.size();
int sum = accumulate(nums.begin(), nums.end(), 0);
int m = n / 2;
unordered_map<int, unordered_set<int>> map;/* cnt,sum */
for (int i = 0; i < (1 << m); i++) {
int temp = i;
int tsum = 0, tcnt = 0;
int idx = 0;
while (temp > 0) {
if (temp & 1) {
tsum += nums[idx];
tcnt++;
}
idx++;
temp >>= 1;
}
map[tsum].insert(tcnt);
}
int r = n - m;
for (int i = 0; i < (1 << r); i++) {
int temp = i;
int tsum = 0, tcnt = 0;
int idx = 0;
while (temp > 0) {
if (temp & 1) {
tsum += nums[m + idx];
tcnt++;
}
idx++;
temp >>= 1;
}
for (int j = max(1,tcnt); j < n; j++) {
if (j * sum % n != 0) continue;
int t = j * sum / n;
if (map[t - tsum].count(j - tcnt)) return true;
}
}
return false;
}
};

有的没的

没事听点歌(金玟岐 - 岁月神偷)