转:知乎某关于Java技能树的回答

昨天在知乎看到一个回答,大意是:如果我是面试官,就从Object类的方法问起。

clone方法可以问深复制浅复制,接下来可以涉及引用类型和值类型,自动装箱拆箱,类初始化顺序……
equals方法可以问Integer的缓存,JVM内存模型,然后转到hashCode;
finalize方法可以问作用、缺点,转到垃圾回收,GC的算法、实现、内存模型……
getClass方法当然问反射,接下来可以拓展到Spring,扩展到动态代理 aop……
hashCode可以考基本算法,HashTable和HashMap,是否线程同步,然后可以考并发容器……
wait/notify/notifyAll引申出多线程,接下来就是并发包中的各种调度器线程池……

翻了翻链接,原回答:你遇到过哪些质量很高的 Java 面试? - 知乎
https://www.zhihu.com/question/60949531/answer/182146087

走进Java并发编程01

前言

本系列文章作为学习笔记,记录了博主向并发包进攻的过程。

在此,我假设本文读者已经掌握了Java的基本编码,如果是大学生,至少Java的课程要通过;如果是培训学员,至少需要学完SE部分。虽然影响不大,但是如果对Spring,或者至少Java Web有一定了解和编码经验,应该会对阅读有所帮助(我可能会举一些网站相关的例子)。

本文夹杂大量个人理解,错误不可避免,非常欢迎评论指正。

正文

第一章 :假如没有juc……

2004年9月30日18:00PM,J2SE1.5发布,成为Java语言发展史上的又一里程碑。为了表示该版本的重要性,J2SE1.5更名为Java SE 5.0。——via 维基百科

前置知识:JSR——Java Specification Requests的缩写,意思是Java 规范提案。是指向JCP(Java Community Process)提出新增一个标准化技术规范的正式请求。

JSR166完成(同样也是J2SE1.5发布)之后,Java程序员发现,除了Thread类、Runnable接口外,Java引入了大量的API,极大的丰富了Java在多线程编程的能力。

让我们从已掌握的知识开始,先忘掉java.util.concurrent,复习一下基础的概念:线程/进程/并发/并行,还有我们的老朋友Thread/Runnable/synchronized/volatile。


第一节:进程与线程。

在提到线程和进程之前,推荐一篇有趣的文章:
《我是一个CPU:这个世界慢!死!了! 》
(阅读前需要对计算机的基本组成有一定了解,至少要明白SSD、CPU的L1L2缓存是什么。)

如果将一颗2.6GHz频率,一个逻辑核心的CPU(这个性能在现在可以说是很低了,主流的i7-8700K有6个物理核心,通过Intel的超线程技术让系统认为有12个逻辑核心,最高睿频4.7GHz)执行每个指令的时间放大到一秒,那么从内存中读取1MB 需要7.5天,从SSD上的随机读取需要用4.5天,从SSD读取1MB需要一个月,而HDD的磁盘寻址需要10个月,连续读取1MB需要20个月!

我们设想一个场景。你打开一台一个逻辑核心的电脑,登录QQ,同时打开IDEA开始编写代码,在你眼里QQ和IDEA是同时运行着的程序。但是在CPU的眼里,QQ和IDEA需要CPU来执行的操作其实是轮流着来的,只是它切换的速度太快,你感受不到!

CPU其实一直不断的在加载QQ的上下文(内存、显卡、硬盘等资源)→执行QQ→保存QQ的上下文→加载IDEA的上下文…

好,我们引申出概念:

1.什么是进程?

“计算机中已运行程序的实体,分配资源的基本单位。”

我们编写的程序,只是指令(比如做加法)、数据(比如一个整数1)和组织形式(比如某个数组,是按顺序排的一系列元素)的描述,当我们下达运行它的命令时,才会产生进程。操作系统按进程为单位分配资源,这些资源包括一片内存、操作系统描述符(所谓文件句柄/文件描述符),安全特性以及CPU状态(视是否在运行存储在寄存器/内存中)等。

总结:进程是一个单位,我们下达运行程序命令时,就可以向操作系统申请领取“一个进程的”大礼包,包含了一段时间内的一部分计算机资源。

2.什么是线程?

“操作系统调度的最小单位”。
在此不对OS这门课做展开,不讨论内核态用户态线程。

我们再设想一个场景。你从操作系统领取了大礼包,它包含了一部分的时间(我感觉生命在流逝)和一部分的计算机资源。但是现在你的代码里需要打开硬盘上的某个文件,或者更过分的是等待某个网络请求(还记得150ms在CPU的眼里是12.5年吗?)……

这是一种严重的浪费!在这个礼包、甚至之后的好多礼包给的时间内,你创建的进程什么都没有做,白白的浪费了系统资源,时间一到CPU就切换到了别的程序,这极大的影响了程序的执行效率。

这时候如果我能把进程拆分为一个个部分,由它们共享这些资源;当第一个部分需要等待某个耗时较长的操作时,其他部分也可以在这份时间里利用这份资源,岂不美哉?

总结:线程是一个更小的单位,“一个进程”的资源,可以由多个“线程”共享,免得出现资源浪费。

3.什么是并发,什么是并行?

Erlang之父简单的介绍了它们的区别:

串行是一个队列一台咖啡机,如果有人匹到了一把Dota,过了一个小时打完回来接咖啡,后面的人都得必须等着;
并发是两个队列用一台咖啡机,咖啡机在处理两个队列的人的状态中不断切换,在逻辑上这台咖啡机可以同时处理两个队列;
并行则是两个队列用两台咖啡机,有两台咖啡机在同一个时间点处理两个队列,注意,是真正意义上、物理上的同时,这也是多逻辑核心CPU的模型!

白菜日记6

快半年了,白菜的功能也发生了很多变化。
加入了对接osu search接口,可以提供搜索词来找到自己在某个图的成绩。
加入了其他三个模式的支持(懒得从其他语言移植其他模式的PP计算)。
另外还有cost和bounspp的计算……
记不太清这五个月做了什么了,现在有了AOP的异常通知、参数拦截,redis缓存……
现在说起来好像挺简单,不过实现的时候还是挺开心的。

比赛分析也改成了用命令增删玩家、谱面……

涤尘迎春更新(不)

前几天白菜突然被邀请进入一个站长群,恰好还遇上了熟人手柄。
突然想重新整理一下自己的博客,毕竟这不是游戏博客,放一堆Dota 2的大背景好像很不合适。
干脆换一个仿Github的主题,也开始整理一些自己平时遇到的东西吧。

关于finalize(),System.gc()和C#、C++的析构函数的一点笔记

2018-4-2 补充:

在阅读了深入理解JVM后,作者的这一段话彻底理清了这一块:
“需要说明的是,上面关于对象死亡时finalize()方法的描述(前文描述了在finalize()方法中让对象重新被引用,从而可以避免一次垃圾回收)可能带有悲情的艺术色彩,…笔者建议大家避免使用它,…而是Java刚诞生时为了使C/C++程序员更容易接受它做出的妥协。它运行代价高昂,不确定性太大,无法保证各个对象的调用顺序,有些教材描述它适合用来关闭外部资源,这完全是对这个方法用途的一种自我安慰,finalize()能做的,finally块等其他方式能做的更好、更及时,所以笔者建议大家完全可以忘掉Java中有这个方法的存在。”

这个方法实质是一种妥协,缺点见下文。

先说finalize。

在我之前的个人理解里,finalize方法并不会直接导致该对象被释放/回收;因此它不是C++意义上的析构函数。
(事实上析构函数的作用是定义对象被释放时候的行为,是对析构函数的调用,而不是析构函数本身导致对象被释放)
它用于 标记在对象被回收时应该做一些事情;例如释放其他资源,关闭连接等。

以下说明摘抄自1.6的API文档:

当垃圾回收器确定不存在对该对象的更多引用时,由对象的垃圾回收器调用此方法。子类重写 finalize 方法,以配置系统资源或执行其他清除。
finalize 的常规协定是:当 Java虚拟机已确定尚未终止的任何线程无法再通过任何方法访问此对象时,将调用此方法,除非由于准备终止的其他某个对象或类的终结操作执行了某个操作。finalize 方法可以采取任何操作,其中包括再次使此对象对其他线程可用;不过,finalize 的主要目的是在不可撤消地丢弃对象之前执行清除操作。例如,表示输入/输出连接的对象的 finalize 方法可执行显式 I/O 事务,以便在永久丢弃对象之前中断连接。
Object 类的 finalize 方法执行非特殊性操作;它仅执行一些常规返回。Object 的子类可以重写此定义。
Java 编程语言不保证哪个线程将调用某个给定对象的 finalize 方法。但可以保证在调用 finalize 时,调用 finalize 的线程将不会持有任何用户可见的同步锁定。如果 finalize 方法抛出未捕获的异常,那么该异常将被忽略,并且该对象的终结操作将终止。
在启用某个对象的 finalize 方法后,将不会执行进一步操作,直到 Java 虚拟机再次确定尚未终止的任何线程无法再通过任何方法访问此对象,其中包括由准备终止的其他对象或类执行的可能操作,在执行该操作时,对象可能被丢弃。
对于任何给定对象,Java 虚拟机最多只调用一次 finalize 方法。
finalize 方法抛出的任何异常都会导致此对象的终结操作停止,但可以通过其他方法忽略它。


JDK9的API中,该方法已经被废弃:

已过时。 finalize机制本质上是有问题的。finalize可能导致性能问题,死锁和挂起。
finalize方法中的错误可能导致资源泄漏; 如果 finalization不再需要,无法取消 ; 并且在不同对象的finalize方法的调用中没有指定排序。 此外,finalization的时间并不能得到保证。finalize方法可能只能在无限期的延迟之后,才调用到可终结的对象上。
如果一个类的实例持有非堆资源,那么它应提供一种方法,来实现这些资源的显式释放,如果适用,它们还应实现AutoCloseable 。
Cleaner和PhantomReference在对象变得不可达时提供更灵活和更有效的方式来释放资源。

API Note:
嵌入非堆资源的类,具有许多清除这些资源的选项。 该类必须确保每个实例的生命周期 比其嵌入的任何资源的寿命都要长。
当嵌入在对象中的资源正在使用时,可以使用Reference.reachabilityFence(java.lang.Object)来确保对象保持可访问。

一个子类应该避免覆盖finalize方法,除非子类嵌入在收集实例之前必须清理的非堆资源。
与构造函数不同,调用子类的finalize()不会自动调用父类的finalize()方法。如果一个子类覆盖了finalize,必须明确地调用超类终结器。
为了防止异常提前终止终结链,子类应该使用一个try-finally块来确保总是调用super.finalize() 。 例如:

1
2
3
4
5
6
7
8
@Override 
protected void finalize() throws Throwable {
try {
... // cleanup subclass state
} finally {
super.finalize();
}
}

结论:在1.9之后,我们不应该用这个方法来实现 让一个类被回收时做某些事 的功能。如果需要做某些事,应当显式的提供做这些事的方法,例如实现AutoCloseable接口,用虚引用和Cleaner等新版的功能。

再说System.gc()。

从入门开始,我便被各种书籍告知,不要手动调用System.gc()。那么这个方法到底做了什么呢?是立即发起垃圾回收,还是催促虚拟机进行一次垃圾回收呢?
看看API文档:

调用gc方法表明,Java虚拟机花费了回收未使用对象的努力,以使其当前占用的内存可用于快速重用。
当控件从方法调用返回时,Java虚拟机已经尽力从所有丢弃的对象中回收空间。

不知道具体JVM的实现是什么,至少这段话我的理解就是 立即发起一次垃圾回收……
由于垃圾回收会停止主程序活动,而且的确实际开发中也没有理由手动发起垃圾回收,总之还是不要用的比较好(

联动:C#的析构函数

学习finalize的时候,往往会同时提起析构函数这一C系语言的产物。
但是在C#中,析构函数和finalize的作用,在我的理解中是一致的:即 让这个类在被回收前做一些事情。

不过C#的析构函数并没有被废弃,微软同时建议使用IDispose接口,实现Dispose()方法。
不过和Java的AutoCloseable不同,Dispose方法需要被重载两次,第一次是表示该对象不执行析构函数:

1
2
3
4
5
6
7
public void Dispose()
{
// Dispose of unmanaged resources.
Dispose(true);
// Suppress finalization.
GC.SuppressFinalize(this);
}

第二次才是真正的执行逻辑,此时的重载是Dispose(bool),如果对它方法的调用是来自Dispose(),则参数为true;如果来自析构函数,则参数为false。因此需要对参数进行判断:当调用来自Dispose()时,释放非托管资源。

联动:C++的析构函数

水平有限,不做拓展.
大致可以看出也是在对象生命周期结束时被自动调用,但是由于没有自动内存管理,C++的析构函数会在如下几种情况被调用:

  • 使用 delete 运算符 显式解除 分配了使用 new 运算符分配的对象。 使用 delete 运算符解除分配对象时,将为“大多数派生对象” 或 “属于完整对象,但不是表示基类的子对象的对象”释放内存。 此“大多数派生对象”解除分配一定仅对虚拟析构函数有效。 在类型信息与实际对象的基础类型不匹配的多重继承情况下,取消分配可能失败。
  • 具有块范围的本地(自动)对象超出范围。
  • 临时对象的生存期结束。
  • 程序结束,并且存在全局或静态对象。
  • 使用析构函数的完全限定名显式调用了析构函数。

白菜日记5

之前的名字好像太长了(

自动清理文件我直接写死了Linux的路径,反正也不会在开发环境下用到这个功能(
今天折腾了个验证码,初步看了一下Interceptor和Filter的区别,前者是基于AOP来拦截每个Controller请求,本质上是个Aspect,和我用来全局捕捉异常的东西是一样的。。
后者则是J2EE里Servlet的功能,在Spring的DispatcherServlet之前工作,在Controller工作之前就拦截了请求……
虽然其实功能差不多,传入的都是HttpServletRequest/Response,不过我的代码要用到redis,而redis的工具类是托管在Spring里的,所以我还是选了Interceptor。

这个比赛分析功能还是挺有用的……后期考虑改成命令(

晚点改一下分数抓取的目标和继续做登录吧(

白菜的开发日记4

为了节省费用,我尝试将白菜移植到一台必须有Linux环境的机子上运行(为了和节点共存)。
一开始打算使用WSL,无奈这东西在桌面Win10已经普及了,但是Server系统下只有一个Insider Preview。

Preview到什么程度呢,那个系统没有Explorer,甚至没办法移植(comctl32.dll还是哪个系统文件是定制的,没法覆盖也没法兼容,毕竟VPS可没有PE和安全模式)。

在一番挣扎之后我们彻底放弃了它,改为Ubuntu,于是我的灾难来了……

在此之前我也曾经在CentOS等系统下配过Java+MySQL的环境,但是酷Q是用易语言写的,于是就涉及到一个Docker的使用问题。

在坑了一天之后(其实是我耐不下心去啃文档,只能自己瞎几把试),大概了解了这点:
docker pull是下载一个镜像,类似于创建一个类
docker run是创造一个容器,类似于实例化对象
而docker start和stop是操作这个容器,docker rm是删除容器(对象),docker rmi是删除镜像(类)。

大概遇到的坑有:
在docker内运行的酷Q,虽然能提供5700的HTTP Server,但是同一台机子上运行的Tomcat开放的8080端口它却无法作为客户端使用,
指定localhost:8080和127.0.0.1:8080都无效,必须把酷Q的消息上报地址改为服务器的公网IP(我实际上是改为了域名)

我的代码里需要处理ppy给的类似于 1970-01-01 00:00:00的日期格式,
在Win下面,Gson默认转换没有问题,但是Ubuntu下就不行(似乎和时区无关,我本地的WSL也是这样),必须硬编码一个日期格式才能起作用。

这次重构,把CQService里具体的处理拆出来一个CmdUtil,实体类、util全部分包处理,
为了避免Win/Ubuntu的路径不一致将所有素材以二进制形式存到了数据库……四个语音base64编码之后直接硬编码到程序里(

同时还发现,我之前给ImgUtil加了一个static的Map,然后写了个静态方法去用NIO爬目录,把图片塞到那个Image里,最后在构造器里调用这个方法……
但是我ImgUtil是用prototype模式注入,每次有HTTP连接都会实例化一个新的ImgUtil,重新爬一次,压根没起到缓存的作用_(:з」∠)_

还把oppai换成了Java实现,那作者自称不喜欢Java,也别问他要更多Java程序或者支持,虽然oppai的功能全部搬了过去。
用了一下,我发现他用了一大堆静态内部类,而且要使用它 必须把它和调用它的类放在同一个包里(有个关键东西的构造函数是包访问权限的)。
本来想自己改造,但是想到万一PP算法一改,作者一更新那我不就歇逼了?于是老老实实按他规定的方法来_(:з」∠)_

一开始没掌握正确的使用方法,于是昨晚上漏掉了HR和DT(我把MOD应用之前的star穿进去了,柑橘妖怪作者不提供详细的example)。

再一次体会到了“你以为你写的代码是这样跑的,其实根本不是”的心情。

接下来就是写网站部分,然后还有一个准备咕很久的东西……还有白菜的邀请入群机制也要改改,自动清理文件的机制也要改,不过这两天我有点心力憔悴,先咕着吧……

白菜的开发日记3

许久没更博客了,写点白菜最近的进度吧。

整个Web版重构完成,我还在路由器上搭了一个mysql server,然后用mysqldump每天凌晨备份白菜的数据库。

顺手加上了凌晨把获取失败的ID以邮件形式发到自己邮箱,还加了清空当日生成的临时文件。

弱智啪啪啪提了一个req,!sleep 按小时为单位禁言自己,没想到做出来真的一堆弱智用……

日记2里提到的绘图类,被我从静态代码块改成了静态方法,然后在!sudo bg命令时调用它(否则会导致修改用户bg不能马上生效)。

顺手做了一个!fp功能,不过mp4和5没什么人用……

我还研究了一下HTTP Client,做了模拟登陆osu并且下图,还写了解析.osu的正则表达式,来从官网获取BG。

甚至还处理了0,0,”sb\bg.png”这种情况……

在这里吹自己一波,我居然想到了把官网下图时的InputStream包装为ZIPInputStream,直接在内存中解压osz……

写完才发现“卧槽我居然真的把这个想法实现了”。。可以说白菜现在的粗壮性又上了一个台阶(

Java中的值传递和引用传递

今天突然提到Java传递变量的方式,研究了一下值传递和引用传递。

我的结论是:Java是以值传递的方式传递对象的引用/基本数据类型本身。

可能听起来有点拗口,首先明确一下值传递和引用传递。

值传递,是在方法内无法通过赋值等手段改变这个变量本身,因为传递过程中这个变量被复制了一份传入方法内,方法内对这个形参的操作并不会影响方法外。

而引用传递,则是将对象本身的内存地址传入方法内,方法内操作这个变量会对这个内存地址上这个变量的本体进行操作。

首先Java的方法里,用赋值等号=是改变不了调用者里形参的值的,因此传递的方式是值传递无误。

当然,如果调用这个对象本身的方法去改变自身(例如StringBuilder的append方法,或者是Javabean的Setter方法)是可以做到的,因为这个引用依然引用着这个对象,只是这个对象本身变了而已。

所以给数组的某个索引赋值之后,是可以改变数组本身,因为数组本身也是对象,继承自Object……只不过是一个特殊的对象,可以参考
http://www.blogjava.net/flysky19/articles/92763.html?opt=admin
数组的类是运行时生成的,它没有构造方法。证明数组是对象的最简单方法是:用等号赋值时并没有克隆一份数组,而只是重新指定了引用,要克隆数组得用System.arraycopy()方法。

所以手写排序时递归调用本体,并不会内存爆炸,因为数组本身并没有被复制。

(以上仅仅是个人看法,欢迎指正)

白菜的开发日记2

白菜之前的代码基本已经稳定运行了,于是我准备把它改成一个Web项目,采用HTTP API来收发消息,同时将来可能扩展出网页什么的……

在重构的时候遇到一个问题:之前我在绘图类,用nio扫出结算界面需要的所有图片,然后用static代码块包裹:

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
static {
final Path resultPath = Paths.get(rb.getString("path") + "\\data\\image\\resource\\result");
//使用NIO扫描文件夹
final List<File> resultFiles = new ArrayList<>();
Images = new ArrayList<>();
Nums = new ArrayList<>();
Mods = new ArrayList<>();
SimpleFileVisitor<Path> resultFinder = new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
resultFiles.add(file.toFile());
return super.visitFile(file, attrs);
}
};

try {
//将所有文件分为三个List
java.nio.file.Files.walkFileTree(resultPath, resultFinder);
for (int i = 0; i < 23; i++) {
Images.add(ImageIO.read(resultFiles.get(i)));
}
for (int i = 23; i < 37; i++) {
Nums.add(ImageIO.read(resultFiles.get(i)));
}
for (int i = 37; i < 48; i++) {
Mods.add(ImageIO.read(resultFiles.get(i)));
}
zPP = ImageIO.read(resultFiles.get(48));
zPPTrick = ImageIO.read(resultFiles.get(49));
} catch (IOException e) {
logger.error("读取result相关资源失败");
logger.error(e.getMessage());
}

这样这些对象只会在这个类第一次被加载的时候生成,避免了每次绘图重复扫描文件夹,降低效率的问题。

然后后面画图的时候,我只需要调用List中的BufferedImage对象

1
2
3
//右下角两个FPS
g2.drawImage(Images.get(1), 1300, 699, null);
g2.drawImage(Images.get(2), 1300, 723, null);

但是如果我要新加功能就会显得不便:我只能添加z开头的文件名,否则我得把所有的Images.get()后面的数字调一下。。

看了一眼它们的文件名,我觉得ppy应该是在osu启动的时候把皮肤全部加载到内存,然后根据文件名绘制……

看了一下nio,好像能生成文件名,那我把List换成Map好了……