前言
每次我忘带充电器,而不得不拿着非原装充电器使用的时候,心里总是想:这么充不会把我手机给充坏了吧;每次问他人借数据线的时候,总是不知道该说借哪种线,今天打算花点时间好好整理一波。
在正式开始Java的多线程之前,首先了解一下这个在jdk8中正式加入的为了简化操作而加入的lambda表达式。
为了便于理解,打算从最简单的开始:
1 | public class ThreadTest { |
上面这段代码是比较简单的,自己定义了一个接口,且这个接口只有一个方法(这对lambda是必须的,更加精确的说法应该是仅包含一个抽象方法),然后有一个类去实现了这个接口。最后我们在主类中调用了一下接口。
之前学习Java的时候并没有学到io这一块,然后之后用到的时候都是随手网上找一段封装好的代码直接使用,有心去了解过一下,但是看到java.io包下那庞大的类,想想暂时先放下了;这次趁着春节疫情假期延长的时间来好好复习一下,最后发现还是蛮简单的。
这里我选取了C、Python和Java这三种语言来作为例子,看看它们三者之间的io之间的区别。
1 | #include <stdio.h> |
C语言的操作很简单,首先由一个指针指向一个文件,然后使用特定的函数对其进行读写,最后对文件进行关闭即可。
1 | #!/usr/bin/env python |
显然Python更简单…..指定好文件,然后进行操作就行了。
读取文件内容:
1 | public static void main(String[] args) { |
写出文件的内容:
1 | public static void main(String[] args) { |
看上去比上面的两位都复杂一点,但是不要忘了,主要是因为Java加了不少的异常处理机制,使得代码更加健壮。
从写代码的效率来说,Python必然是最优选择,而C语言和Java在写代码方面我个人觉得差距不大(因为C这里没有异常处理机制)。但是对于初学者来说,可能还是C和Python这两种语言更符合大家的常理:打开文件,对文件进行处理,最后关闭文件。Java看上去似乎很奇怪,并不是直接对文件进行操作,而是用了Stream来进行处理。这里有个问题,为什么Java要这么做?emmmmm其实我到现在还是无法理解,我个人觉得一种可能的原因是,Java使用了虚拟机,导致它无法直接对文件进行操作,所以用到了流来间接进行处理。
###字符和字节
这里还是有必要来介绍一下这两者的区别。
一个字节(byte)是八个比特,所以其本质上,是一个数字,一个从0到255的数字。在Java中有一个基本的数据类型就是byte,但是由于Java不支持无符号数据类型,所以在Java中,byte这个数据类型所能表达的数字范围是-128~127(这里多嘴提一句,byte数据一般也用来表示ip地址,所以看到负数不要惊讶)下面的一张图很能说明问题:

而字符,在Java中是用char来进行表示的,一共是2个字节。显然能够表示65536个字符。然后在《Thinking in java》这本书里,居然说char能够表示所有的中文字符。这句话显然是错误的,光是汉字就不止六万多字,更别说还需要包括别的语言的字符了。当然如果只是常用字符,确实char是完全能够表示的。
###编码和解码
维基百科对其的定义是:
也就是说,其实两者是等价的。但是习惯上,我们把从字符到字节成为编码(encode),从字节到字符称为解码(decode)。
编码和解码其实特别简单,只要我们遵守编码和解码采用相同的字符集,就不会有问题。假设现在有这么一套编码,它会把你编码成 0100 0001(十进制的65),把好编码成0100 0010(十进制的66)到电脑中保存。然后如果程序读取的时候,它按照了ASCII进行解码,就会解读出AB,也就是”乱码”了——即你明明写了你好在文件里,但是程序读出来却成了另外的内容(AB)。
所以关键其实在于这个字符集的选取,字符集那就首先从ASCII讲起。上世纪六十年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。而这套编码正好规定了128个字符,也就是说只用7个比特即可表示(0000 0000 - 0111 1111),而之前也提到Java中最高位代表的是符号位,所以ASCII用byte的正数表示刚刚好。
由于这套编码最多也只能由256个字符,对于中文这样的文字来说肯定是不够的,所以经过一系列的发展,有了Unicode这个字符集,囊括了世界上所有的文字(甚至连emoji都包括其中),这样相当于计算机王国有了自己的普通话标准,只要大家都使用这本大字典,就不会有问题。
看似问题是不是解决了呢?现在有了完全不会冲突的字符集,那我只需要按照字符集里每个字符所对应的二进制代码去操作,那不是完全不会有乱码了吗?没错,理论上完全没有问题。但是实际中并不能这么操作。因为为了能够表达所有的字符,其实Unicode对大部分字符的编码长度是2~4个字节(为了方便说明,我们姑且认为全是3个字节吧),而如果我仅仅为了传输一份纯英文的文档,或者说掺杂了少数中文的文档,那么开销会非常大。于是,我们就有了utf-8。这里需要注意的是:UTF-8 是 Unicode 的实现方式之一。而utf-8也是采用了所谓的变长编码方式以充分利用空间,具体实现过程这里不做赘述。所以如果无视硬盘空间和网络流量,那么其实unicode编码是最舒服最容易理解的(实际上可并没有这种编码方式哦)。
根据你所定义的类型来决定。引自知乎:
char c[] = u8”I’m a UTF-8 string.”; // utf8编码 (假如是C++,那么C++11起为char,C++20起改为char8_t类型)
char16_t d[] = u”This is a UTF-16 string.” // UTF-16编码
char32_t e[] = U”This is a UTF-32 string.” // UTF-32编码
1 | byte = "字符".encode("utf-8") |
1 | String s = "中文"; |
想要不乱码?很简单,编码和解码采用同一种编码方式即可。而往往实际中困扰我们的是,xxx编码格式兼容xxx编码格式,xxx编码格式部分兼容xxx编码格式。还有就是回到java的char这里,char是一个字符,我们如果使用数字来对char进行赋值,在Java内部是会发生解码的(从字节到字符),而我查阅了一下数据,java默认用的就是utf-8。可以通过下面的代码查看:
1 | System.out.println("Default Charset=" + Charset.defaultCharset()); |
前面用了不少篇幅来讲述字符和字节、编码和解码相关的信息,这是因为这些其实在IO中还是很重要的(而且我之前也不是非常了解2333333),从这里正式介绍Java中的各种流。
首先用Stream结尾的一定是字节流,而从它们俩的名字来也能一眼看出它们的作用。这里需要注意的是,这两个类几乎是所有处理流的父类,所以这里用多态非常容易。
InputStream可以读取单个字节,读到最后一个字节的时候会返回-1,由此来判断。当然更多的时候,是我们自己创建一个字节数组,然后读取字节到字节数组中,会返回实际读取到的字节数,来进行处理。
OutputStream需要注意的是,记得使用flush进行数据的强制写入硬盘(文件),否则可能会看不到结果。
上面的两个的实现类而已,如果我们需要读取/写入文件,记得来操作这两个类。
专门用来操作字符的抽象类,会比字节流方便一丢丢。
具体的实现类。
装饰类,所以只需要把需要装饰的丢入即可。这里注意的是装饰之后新增了一个readLine()方法。
主要是为了针对基本数据类型。
序列化和反序列化操作对象。记得实现一个空接口。
实际一般使用这个第三方库。这里介绍下IDEA如何使用这个库:
File->Project structure,然后选择libraries,导入即可。日常生活中有的时候会需要用到脚本来进行一些便利的操作,比如抢购某些东西之类的。而商家为了防范用脚本来进行操作,一般会使用一些防范措施。其中最常见的应该就是验证码了。
全自动区分计算机和人类的公开图灵测试(英语:Completely Automated Public Turing test to tell Computers and Humans Apart,简称CAPTCHA),俗称验证码,是一种区分用户是计算机或人的公共全自动程序。
现在的验证码越来越多,比如滑动滑块让你拼图的、直接给你手机发短信的等,这篇博客并不打算涉及这些比较复杂的,只是简单介绍一下简单的验证码以及如何去使用代码来实现简单的验证码识别。
本博文对应《深入理解java虚拟机》第七章的内容。
java编译器把java文件变成了字节码,而JVM则是首先把字节码加载到内存,对数据进行校验、转换解析和初始化,最终就可以形成被虚拟机直接使用的java类型。不像C在编译的时候进行了连接工作(这里主要说的是静态链接),在java中加载、连接和初始化其实都在程序运行的时候完成的,虽然会耗时间,但是能够动态加载,这样大大提高了灵活性。
类从开始被加载到内存到被从内存中移除为止,一共要经历七个步骤:
其中验证——准备——解析这三个步骤被称为连接(linking);而如果是为了能够支持java的运行时绑定,还可以把解析放到初始化后面。
有且仅有以下的六种情况必须进行初始化:
MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic和REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则需要首先触发初始化。上面这五种都被称为主动引用,接下来用三个例子说明什么是被动引用。
例子1:
1 | public class SuperClass { |
只会输出Super Class init和123,也就是说虽然获取的是subclass类的值,但是并没有初始化subclass。因为对于静态字段,只有直接定义这个字段的类才会被初始化。
例子2:
1 | public class Test { |
你会发现什么输出都没有。
例子3:
1 | public class ConstClass { |
你会发现只输出了hello world!但是没有输出Const Class init那段代码,这是因为在编译阶段及逆行了常量优化,直接把那个字符串常量存储到了Test类中的常量池里面。也就是这两个类在你编译完成之后,就没有任何关系了。
接口和类的加载过程稍微有一点点不同。因为static会触发类的clinit高早起,而接口没有static语句块。但是编译器仍然会为接口生成clinit构造器。一个接口在初始化的时候并不要求其父接口全部完成了初始化。
上面那个加载是广义上的加载,具体是加载——验证——准备——解析和初始化这五个步骤。
在这个阶段主要要完成3件事情:
java.lang.Class对象,这样就可以访问对应的方法区的数据了。注意这三条都是很宽泛的要求,具体怎么实现完全可以很灵活。比如第一步,我从哪里获得这个class文件呢?可以是从网络中传输过来的(Applet),直接从压缩包里获取(JAR),直接由运算生成(动态代理技术),甚至还可以直接从数据库里获取。
非数组类的加载是比较灵活的,你可以选择重写类加载器的loadClass()方法或者是直接用系统提供的引导类加载器来完成。而数组类就只能由JVM直接创建,但是数组类中的每个元素其实还是需要类加载器来加载这个元素的,而这个元素又有两种可能:
加载完成之后,.class文件的内容就被复制了一份到方法区之中。然后在内存中会实例化java.lang.Class类的对象(注意这里说的是内存中,而没有说堆,因为这个对象很特殊)
从这里开始进入连接步骤了。首先问一句,为什么需要进行验证?这是因为class文件可以用任何别的方法来生成,比如可以直接用十六进制编辑器来写class文件,这样就可以写出恶意的代码,一旦恶意的代码没有经过虚拟机检查就直接运行了,后果不堪设想(当然检查也只是为了减少攻击)。但是如果是你自己编写的代码,其实你完全可以-Xverify:none来关掉(因为验证真的很费时间),因为总不至于自己害自己吧。
首先确认是不是class文件(咖啡宝贝开头),然后看看版本号能不能被虚拟机处理,再看看常量池里有没有问题……总之这一阶段的主要目的是保证字节流能够被正确的解析并且存储在方法区里,而完成了这个步骤之后接下来的验证就都事基于方法区的存储结构了,不再是直接操作字节流了。
主要是对语义进行分析。随便举几个例子:类必须要有父类(除了Object类),不能重载出错等等等。
此步骤确保程序语义是合法的。比如保证跳转指令不会跳转到别的方法体里面。显然这一步其实是非常非常难的,因为其高度复杂。
符号引用中的全限定名能否找到相应的类,检查一下有没有相对应的字段和方法,各个类和字段的访问性等。
这个阶段是为类变量(注意是类变量,看清楚!)分配内存并设置初始值的,而类变量所用的内存都在方法区开辟。实例变量是和对象一起分配在堆里面的。这一阶段会把所有的变量初始化为零值。比如public static int value = 123;,会把value初始化为0(真正赋值成123是在之后的初始化阶段,不是现在)。有一种情况是有特例的,如果你声明类变量的时候加上了final,那么编译器就会为字段表增加ConstantValue值,这样value就直接等于123而不再变动。
首先需要解释清楚两个概念:
a)来描述所引用的目标。最典型的例子就是之前分析class文件的时候,会发现引用了常量池里的某个字符串,那个就是符号引用。其次是解析是有缓存机制的。
假设目前代码所在的类是D,要解析一个从未解析过的符号引用N,将其解析为另外一个类(或者接口)C的直接引用:
如果解析一个字段,首先会对字段表里面的class_index中的CONSTANT_Class_info(我知道你已经忘得差不多了,这东西就是在常量池里面)进行解析,如果失败则直接失败,如果成功,那么会对这个字段所属的类的后续的字段进行搜索。
如果这个类本身就含有简单名称和描述符都互相匹配的字段,那么只需要返回这个字段的直接引用即可。没找到的话首先看看这个字段所属的类有没有实现某个接口,然后从下到上开始寻找父接口,同样也是如果找到了简单名称和描述符互相匹配的字段就返回直接引用。然后再看继承关系,和接口一样的找法。如果还是没有,就要抛出NoSuchFieldError异常。但是就算成功找到了,也要进行权限验证,如果不具备对字段的访问权限,那么就会抛出IllegalAccessError异常。
你可能会问要是在父接口和父类中同时出现了同一个字段怎么办?编译器是会拒绝编译的。
首先需要解析方法表里面的class_index里面的所属的类或者接口的符号引用。如果发现是个接口,那么就直接抛出IncompatibleClassChangeError异常。通过了的话就能去找是否有简单名称和描述符都匹配的方法,如果有的话就返回直接引用;没有的话就去父类中找,否则再去父接口中找到。如果最后在父接口中找到了,则该类是个抽象类,需要抛出AbstractMethodError异常。
首先还是在class_index里面找,因为这里是接口了,所以如果发现是个类,则需要抛出IncompatibleClassChangeError异常。然后在找找所属的这个接口中有没有直接匹配的,再去父接口中寻找,最后没找到则抛出NoSuchMethodError异常。
类初始化是类加载的最后一步了。这里才开始真正执行字节码文件。之前提到过在准备阶段类变量已经赋予过一次零值,初始化阶段则是根据程序员编写的代码来进行初始化。简单讲就是执行clinit()方法(这个方法是类构造器,而不是通常的构造器)的过程。这个方法是编译器自动收集类中的类变量的赋值动作和static语句块(即静态语句块)合并产生的。当然收集的顺序就是出现的顺序决定的,静态语句块比较特殊,它只能访问在它之前的变量,而定义在它后面的变量,它可以赋值但是无法访问。
1 | public class Test { |
猜猜这段代码会输出什么?会输出1。这是因为之前也说了,编译器是按照出现的先后顺序(即静态语句块在前,类变量赋值为1在后)的操作来进行收集的,所以当然是后面的会覆盖前面的啦。不知道你会不会有疑问,为什么i=0这句居然不报错呢?因为在准备阶段会为这个类变量进行赋予零值呀。
注意!到这里为止,我们还没有对类的成员变量(也叫实例变量)进行任何的赋值过。
1 | public class Test { |
上面这段代码是非法的,无法通过编译。
clinit方法被称为类构造器,而与类同名的函数叫做实例构造器init(),虚拟机能够保证在子类的clinit方法执行前父类的已经执行完毕。
init is the (or one of the) constructor(s) for the instance, and non-static field initialization.
clinit are the static initialization blocks for the class, and static field initialization.
显然如果一个类没有类变量和static语句块,那么就不需要clinit方法了。下面看一个实际的例子:
1 | public class Test { |
因为Parent类的clinit会首先被执行,所以A是2,自然程序的结果也是2。
接口和类相比只是少了static静态块。但是接口的clinit方法不需要先执行父接口的clinit方法,除非父接口中定义的变量需要使用,父接口才会初始化。同样接口实现类在初始化时也不会去执行接口的clinit方法。
虚拟机会保证一个类的clinit在多线程环境下通过加锁被正确初始化,但是这会出现死锁问题。下面的代码演示了这个问题。
1 | public class Test { |
上面代码有一个if(true)看上去很沙雕的判断条件,但是其实是至关重要的。
比较两个类是否相等,大前提就是加载这两个类的类加载器是否相同,如果连类加载器都不同,那么就没有意义。
面试中被问烂的问题之一。
从虚拟机的角度来看,只有两种类加载器:一种是启动类加载器(Bootstrap),这个类加载器本身由C++实现,是虚拟机的一部分;另外的类都归为另一类,全部都由java来实现,并不属于虚拟机,全部继承自java.lang.ClassLoader。当然这个是从虚拟机的角度来看的,面试官想要听到的答案是下面的三层类加载器:
$JAVA_HOME/lib下的类库,而且名字必须要符合。如果你希望能够调用这个类加载器,只需要指明加载器为null即可。$JAVA_HOME/lib/ext下的类库,JDK9之后被模块化取代,也没有对应的目录存在了。这个加载器可以直接被使用。双亲委派的意思就是,当类加载器收到加载类的请求的时候,它首先先委派给父类去加载,只有当父类反馈说自己无法加载的时候,自己才会去尝试加载。
JDK9中正式引入的一个模块化系统,但是我还没了解过,所以这部分就暂且先跳过了。
从我开始接触计算机开始,到现在,计算机还是只认识0和1,所以我们写的程序必然要经过一通操作最后变成机器码才可以执行。但是越来越多的程序语言选择了类似java字节码这种和平台无关、和操作系统无关也和机器指令码完全无关的存储格式。
除了平台无关性,还有一点是我之前没有注意到的,它希望能够实现语言无关性:让其他语言运行在java虚拟机之上。这是因为java虚拟机只和字节码文件进行绑定,而不是和java文件进行绑定。所以我完全可以下载一个能够对python进行解释的解释器,让它把我的Python代码变成字节码,然后扔到java虚拟机上去执行。
你可能会奇怪,java是一种强类型的语言,而Python是弱类型的,为什么两者可以同时放到java虚拟机上去运行呢?因为像java的这些关键字,其本质上是多条字节码组合而成的,所以才能够实现如此不可思议的事情。
不知道你有没有遇到过下面的这种情况:
我明明按下了退格键,为什么会出现^H啊,明明平时在shell中输入命令按下退格键功能完全正常,为什么在某些情况下它不是把前面的那个字符删除呢?
又或者是如下图所示:

明明只是按下了一个键,突然出来了乱七八糟的一堆东西。这就要从terminal mode中开始讲起。
终端有两种模式,分别叫cooked mode和raw mode,从字面看就能理解其意思,一个是处理过的,另外一个是不处理的。所以如果你在raw mode中按下backspace,相当于输入了一个控制字符,但是因为是raw mode,它不会对你输入的控制字符进行处理,但是又不得不显示一下(总不能空着吧,不然这么多控制字符怎么区分呢),然后就会用“脱出字符表示法”来进行表示,就是用^开头的这个,所以当你按下退格键的时候你能看到^H。可以参考这里查看所有的ASCII字符的脱出表示法。
所以总结就是:你在cooked模式下如果分别按下1,2,3,退格键和4这五个按键,那么终端会显示124,但是如果你在raw模式下,则会输出123^H4。
我们都知道ASCII字符分成两种,一种是可显示的字符,另一种是控制字符:
raw模式下还是cooked模式下都是一样的,这些字符全部在键盘上,相信你只要多看几眼键盘一定能打出这些可显示字符。^改成ctrl即可。比如^C,那你只需要输入ctrl+c即可输入这个控制字符了。当然你可以通过stty这个命令来修改。而如果你想输入这个字符,你还需要在前面加上一个ctrl+V。可以通过下面的代码来验证。1 | #include <stdio.h> |
就是你输入一个字符,它会帮你把你输入字符的ASCII码打印出来(已经把回车符去掉了)。那么怎么做到下面图中所示的这样呢?

在ASCII中3号是“ctrl+C”,而要是你在键盘上按下了这个组合键,那么程序就停下来了,自然也不会输出了。所以上面是怎么做到的呢?想想看我们在java中是怎么输出双引号的,我们用了反斜杠来作为脱字符。自然而然,在linux中也有类似的脱字符,是ctrl+V。也就是输入ctrl+V,ctrl+c即可。
应用在实际中,比如我们都知道如果要发送GET的数据包,那需要GET / HTTP/1.0\r\n\r\n,来实际操作看看:
1 | nc -p 1234 www.baidu.com 80 |
那我此时如果按下enter,就会发送GET / HTTP/1.0\n出去了,这显然不是我想要的,所以我需要输入\r,而不是让它回车,所以我需要先ctrl+v,然后在输入ctrl+j,这样我就把\r这个字符输进去了,另外同理,就这样输入完成:

最后在输入ctrl+d即可。
PS:最后一个可千万别ctrl+v,然后在ctrl+d哦。这样就把一个数据包发出去了。
PPS:因为现在服务器其实你发送GET / HTTP/1.0\n\n它也会给你答复的,所以其实你按下两个回车也能得到答案,但是这里是为了说明用法。
这个命令能让你修改相关的按键。我们先来看看默认输出吧(ubuntu18.04)

toggle discarding of output…我翻译不出来后面还有好几行,有兴趣的自己man stty看吧。
最早接触到这个命令是在18年12月,和同学聊天的时候聊到了这个命令,但是我不知道这个命令,被他一通鄙视…当时就去简单看了一下,现在过了一年了,打算在这篇博客里面好好整理一下。
nc是netcat 的缩写, 它是类unix系统下一个功能强大的命令行网络工具,被用来在两台主机之间建立TCP或者 UDP连接,并能够通过标准输入输出进行读和写,日常可以配合管道符来进行重定向使用。
我在我的ubuntu下面看到nc其实是一个软连接,分别是/bin/nc -> /etc/alternatives/nc -> /bin/nc.openbsd,而在centos下面则是/usr/bin/nc -> /etc/alternatives/nmap -> /usr/bin/ncat,但是不论在哪里,你都可以使用nc来执行。