《Java编程的逻辑》学习笔记

Java编程的逻辑

编程基础

基本数据类型分类

  • 整数类型:有4种整型byte/short/int/long,分别有不同的取值范围: 8, 16, 32, 64位,(1,2,4,8个字节) 存放(-27~27-1, …, -231~231-1,…)

在给long类型赋值时,如果常量超过了int的表示范围,需要在常量后面加大写或小写字母L,因为数字常量默认为是int类型。 类似的, 对于float,需要在数字后面加大写字母F或小写字母f,例如:
float f=333.33f;这是由于小数常量默认是double类型。

  • 小数类型:有两种类型float/double,有不同的取值范围和精度;
  • 字符类型:char,表示单个字符;
  • 真假类型:boolean,表示真假。

数组

数组存两块: 数组内容+数组内容的首地址(类似指针.) 内容空间分配在堆上, 地址指针存放在栈上. 对象与之相似, 有两块内存, 分别存堆栈. 栈空间在出栈时候释放, 堆空间在生命周期结束后GC自动清理.

==号: 对于数组是判断是否指向同一内存地址(数组内容的首地址), 而不是数组内容是否相等(比较数组内容是否一样需要逐个元素对比)

Object的equals方法是调用==.

switch原理

if、if/else、if/else if/else、三元运算符都会转换为条件跳转和无条件跳转,但switch不太一样。
switch的转换和具体系统实现有关。如果分支比较少,可能会转换为跳转指令。如果分支比较多,使用条件跳转会进行很多次的比较运算,效率比较低,可能会使用一种更为高效的方式,叫跳转表。跳转表是一个映射表,存储了可能的值以及要跳转到的地址。

跳转表为什么会更为高效呢?因为其中的值必须为整数,且按大小顺序排序。按大小排序的整数可以使用高效的二分查找,即先与中间的值比,如果小于中间的值,则在开始和中间值之间找,否则在中间值和末尾值之间找,每找一次缩小一半查找范围。如果值是连续的,则跳转表还会进行特殊优化,优化为一个数组,连找都不用找了,值就是数组的下标索引,直接根据值就可以找到跳转的地址。即使值不是连续的,但数字比较密集,差的不多,编译器也可能会优化为一个数组型的跳转表,没有的值指向default分支。

程序源代码中的case值排列不要求是排序的,编译器会自动排序。之前说switch值的类型可以是byte、short、int、char、枚举和String。其中byte/short/int本来就是整数,char本质上也是整数(2.4节介绍),而枚举类型也有对应的整数(5.4节介绍),String用于switch时也会转换为整数。**不可以使用long,为什么呢?跳转表值的存储空间一般为32位,容纳不下long。**简单说明下String,String是通过hashCode方法(7.2节介绍)转换为整数的,但不同String的hashCode可能相同,跳转后会再次根据String的内容进行比较判断。

函数调用

我们之前谈过程序执行的基本原理:CPU有一个指令指示器,指向下一条要执行的指令,要么顺序执行,要么进行跳转(条件跳转或无条件跳转)。
基本上,这依然是成立的,程序从main函数开始顺序执行,函数调用可以看作一个无条件跳转,跳转到对应函数的指令处开始执行,碰到return语句或者函数结尾的时候,再执行一次无条件跳转,跳转回调用方,执行调用函数后的下一条指令。

计算机系统主要使用栈来存放函数调用过程中需要的数据,包括参数、返回地址,以及函数内定义的局部变量。计算机系统就如何在栈中存放这些数据,调用者和函数如何协作做了约定。返回值不太一样,它可能放在栈中,但它使用的栈和局部变量不完全一样,有的系统使用CPU内的一个存储器存储返回值,我们可以简单认为存在一个专门的返回值存储器。main函数的相关数据放在栈的最下面,每调用一次函数,都会将相关函数的数据入栈,调用结束会出栈。

对于数组arr(对象也是),在栈中存放的是实际内容的地址0x1000,存放地址的栈空间会随着入栈分配,出栈释放,但存放实际内容的堆空间不受影响。(由GC自动回收)

递归

以上就是递归函数的执行过程,函数代码虽然只有一份,但在执行的过程中,每调用一次,就会有一次入栈,生成一份不同的参数、局部变量和返回地址。(直到遇到终结条件才出现返回值. 在此之前返回值存储器都是空的)

扫描二维码关注公众号,回复: 5740338 查看本文章

进制

整数类型:有4种整型byte/short/int/long,分别有不同的取值范围: 8, 16, 32, 64位,(1,2,4,8个字节) 存放(-27~27-1, …, -231~231-1,…)

位权

一个数字的每个位置都存在一个位权

123(10进制)表示1×(102)+2×(101)+3×(100

1110(2进制)=1x(23)+1x(22)+1x(21)+0x(20)

0.1f != 0.1f*0.1f: 二进制的小数位权为2-1, 2-2…,只能精确表示这些数字的组合.

负数的二进制表示

十进制的负数表示就是在前面加一个负数符号-,例如-123。但二进制如何表示负数呢?其实概念是类似的,二进制使用最高位表示符号位,用1表示负数,用0表示正数。但哪个是最高位呢?整数有4种类型byte、short、int、long,分别占1、2、4、8个字节,即分别占8、16、32、64位,每种类型的符号位都是其最左边的一位。为方便举例,下面假定类型是byte,即从右到左的第8位表示符号位。

补码表示法

负数的二进制表示就是对应的正数的补码表示.

补码表示就是在原码表示的基础上按位取反然后加1。取反就是将0变为1,1变为0。

  1. -1:1的原码表示是00000001,取反是11111110,然后再加1,就是11111111。
  2. -2:2的原码表示是00000010,取反是11111101,然后再加1,就是11111110。
  3. -127:127的原码表示是01111111,取反是10000000,然后再加1,就是10000001。
  4. 127+1=-128: 01111111+1=10000000, 就是-128
逆运算&为什么用补码

给定一个负数的二进制表示,要想知道它的十进制值,可以采用相同的补码运算。比如:
10010010,首先取反,变为01101101,然后加1,结果为01101110,它的十进制值为110,所以原值就是-110。直觉上,应该是先减1,然后再取反,但计算机只能做加法而补码的一个良好特性就是,对负数的补码表示做补码运算就可以得到其对应正数的原码正如十进制运算中负负得正一样(取反再加一, 逆运算也是如此, 这一特性很方便)

负整数为什么要采用这种奇怪的表示形式呢?原因是,只有这种形式,计算机才能实现正确的减法和负数加法.

计算结果超出表示范围, 会被认为表示最大负数. byte(-27), short(-215)

位运算

左移<< 右边低位补0, 左边高位舍弃, 二进制: 移动一位相当于x2

无符号右移>>>左边高位补0, 右边低位舍弃

有符号右移:操作符为>>,向右移动,右边的舍弃掉,左边补什么取决于原来最高位是什么,原来是1就补1,原来是0就补0,将二进制看作整数,右移1位相当于除以2。注意:负数的最高位1也会右移,所以所有的负数在右移多次之后都会变成-1(每位都是1)

逻辑运算

计算机进行逻辑运算和位运算效率远远高于其他运算(如±), 所以底层的 高效率的实现往往是使用一些比较复杂的位运算+逻辑运算来实现简单的操作.<Hacker’s Delight>一书多有记载.

逻辑运算有以下几种。
按位与&:两位都为1才为1。
按位或l:只要有一位为1,就为1。
按位取反~:1变为0,0变为1。
按位异或:相异为真,相同为假。
大部分都比较简单,如下所示,具体就不赘述了。

int a=…;
a=a&0x1//返回0或1,就是a最右边一位的值
a=a&0x1111//取a的最低4位, 高位舍弃
a=a |0x1//不管a原来最右边一位是什么,都将设为1

小数

12.345 = 1×10+2×1+3×0.1+4×0.01+5×0.001,与整数的表示类似,小数点后面的每个位置也都有一个位权,从左到右,依次为0.1,0.01,0.001…即10(-1),10(-2),10(-3)等。

很多数十进制也是不能精确表示的,比如1/3. 十进制也只能表示那些可以表述为10的多少次方和的数.二进制是类似的,但二进制只能表示那些可以表述为2的多少次方和的数。

2-1=0.5, 2-2=0.25, 2-3 = 0.125

如果编写程序进行试验,会发现有的计算结果是准确的。比如,用Java写

System.out.println(0.1f+0.1f);
System.out.print1n(0.1f*0.1f);

第一行输出0.2,第二行输出0.010000001。按照上面的说法,第一行的结果应该也不对。其实,这只是Java语言给我们造成的假象,计算结果其实也是不精确的,但是由于结果和0.2足够接近,在输出的时候,Java选择了输出0.2这个看上去非常精简的数字,而不是一个中间有很多0的小数。在误差足够小的时候,结果看上去是精确的,但不精确其实才是常态

计算不精确,怎么办呢?

  • 大部分情况下,我们不需要那么高的精度,可以四舍五入,或者在输出的时候只保留固定个数的小数位(直接舍弃尾数)。
  • 如果真的需要比较高的精度,一种方法是将小数转化为整数进行运算,运算结束后再转化为小数(左移然后右移)
  • 另一种方法是使用十进制的数据类型,这个并没有统一的规范。在Java中是BigDecimal

编码乱码

ASCII: American standard code for internet interchange

ASCII码是基础,使用一个字节表示,最高位设为0,其他7位表示128个字符(巧妙设计, 让其他字符码都可以兼容ASCII)。其他编码都是兼容ASCII的,最高位使用1来进行区分。
西欧主要使用Windows-1252,使用一个字节,增加了额外128个字符。
我国内地的三个主要编码GB2312、GBK、GB18030有时间先后关系,表示的字符数越来越多,且后面的兼容前面的,GB2312和GBK都是用两个字节表示,而GB18030则使用两个或四个字节表示。
我国香港特别行政区和我国台湾地区的主要编码是Big5。
如果文本里的字符都是ASCI码字符,那么采用以上所说的任一编码方式都是一样的。
但如果有高位为1的字符,除了GB2312、GBK、GB18030外,其他编码都是不兼容的。比如,Windows-1252和中文的各种编码是不兼容的,即使Big5和GB18030都能表示繁体字,其表示方式也是不一样的,而这就会出现所谓的乱码,具体我们稍后介绍。

Unicode给世界上所有字符都规定了一个统一的编号,编号范围达到110多万,但大部分字符都在65536以内。Unicode本身没有规定怎么把这个编号对应到二进制形式。
UTF-32/UTF-16/UTF-8都在做一件事,就是把Unicode编号对应到二进制形式,其对应方法不同而已。UTF-32使用4个字节,UTF-16大部分是两个字节,少部分是4个字节,它们都不兼容ASCⅡ编码,都有字节顺序的问题。UTF-8使用1~4个字节表示,兼容ASCI编码,英文字符使用1个字节,中文字符大多用3个字节。

BE: BigEndien LE: LittleEndien

大端模式:

低地址 -----------------> 高地址
0x12 | 0x34 | 0x56 | 0x78

2)小端模式:

低地址 ------------------> 高地址
0x78 | 0x56 | 0x34 | 0x12

记忆:big-endien,end with big, 顺序存储。

UTF-16BE: 大端的UTF-16

编码转换

编码转换的具体过程可以是:一个字符从A编码转到B编码,先找到字符的A编码格式,通过A的映射表找到其Unicode编号,然后通过Unicode编号再查B的映射表,找到字符的B编码格式。
举例来说,“马”从GB18030转到UTF-8,先查GB18030>Unicode编号表,得到其编号是9A6C,然后查Uncode编号>UTF-8表,得到其UTF-8编码:E9A9AC。
编码转换改变了字符的二进制内容,但并没有改变字符看上去的样子。

乱码

理解了编码,我们来看乱码。乱码有两种常见原因:一种比较简单,就是简单的解析错误;另外一种比较复杂,在错误解析的基础上进行了编码转换。我们分别介绍。

解析错误

这种情况下,之所以看起来是乱码,是因为看待或者说解析数据的方式错了。只要使用正确的编码方式进行解读就可以纠正了。很多文件编辑器,如EditPlus、NotePad++、UltraEdit都有切换查看编码方式的功能,浏览器也都有切换查看编码方式的功能,如Fire-fox,在菜单“查看”一“文字编码”中即可找到该功能。
切换查看编码的方式并没有改变数据的二进制本身,而只是改变了解析数据的方式,从而改变了数据看起来的样子,这与前面提到的编码转换正好相反。很多时候,做这样一个编码查看方式的切换就可以解决乱码的问题,但有的时候这样是不够的。

错误的解析和编码转换

文本在错误解析的基础上还进行了编码转换. 例子:

1)两个字“老马”,本来的编码格式是GB18030,编码(十六进制)是COCF C2ED。
2)这个二进制形式被错误当成了Windows-1252编码,解读成了字符“AlAr’。
3)随后这个字符进行了编码转换,转换成了UTF-8编码,形式还是“AYA”,但二进制变成了C380C38FC382C3AD,每个字符两个字节。
4)这个时候再按照GB18030解析,字符就变成了乱码形式“胶胳柳”,而且这时无论怎么切换查看编码的方式,这个二进制看起来都是乱码。
这种情况是乱码产生的主要原因。

这种情况其实很常见,计算机程序为了便于统一处理,经常会将所有编码转换为一种方式,比如UTF-8,在转换的时候,需要知道原来的编码是什么,但可能会搞错,而一旦搞错并进行了转换,就会出现这种乱码。这种情况下,无论怎么切换查看编码方式都是不行的。

乱码恢复

“乱”主要是因为发生了一次错误的编码转换,所谓恢复,是指要恢复两个关键信息:一个是原来的二进制编码方式A;另一个是错误解读的编码方式B。
恢复的基本思路是尝试进行逆向操作,假定按一种编码转换方式B获取乱码的二进制格式,然后再假定一种编码解读方式A解读这个二进制,查看其看上去的形式,这要尝试多种编码,如果能找到看着正常的字符形式,应该就可以恢复。

详细实例见Book. 实际中,我们可以写一个循环,测试不同的A/B编码中的结果形式,Java编程实现:

public static void recover(String str)
	throws UnsupportedEncodingException{
String[]charsets=new String[]{
	"windows-1252""GB18030""Big5""UTF-8"}forint i=0;i<charsets.1ength;i++{
	forint j=0;j<charsets.1ength;j++{
		if(i!=j){
		String s=new String(str.getBytes(charsets[i]),charsets[j]);			 	
		System.out.println("----原来编码(A)假设是:“
			+charsets[j]+",被错误解读为了(B):"+charsets[i]);
System.out.println(s);
System.out.println();
}

以上代码使用不同的编码格式进行测试,如果输出有正确的,那么就可以恢复。

不是所有的乱码形式都是可以恢复的,如果形式中有很多不能识别的字符(如?),则很难恢复。
另外,如果乱码是由于进行了多次解析和转换错误造成的,也很难恢复。 比如以上的代码就是处理单次乱码转换的, 多次乱码转换可能性太多, 而且容易出现不能恢复的字符.

char

Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE(Big Endien)。简单回顾一下,UTF-16使用两个或4个字节表示一个字符,Unicode编号范围在65536以内的占两个字节,超出范围的占4个字节,BE就是先输出高位字节,再输出低位字节,这与整数的内存表示是一致的。
char本质上是一个固定占用两个字节的无符号正整数这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。由于固定占用两个字节,**char只能表示Unicode编号在65536以内的字符,而不能表示超出范围的字符。那超出范围的字符怎么表示呢?使用两个char。**类Character、String有一些相关的方法,我们到第7章再介绍。

char的加减运算就是按其Unicode编号进行运算,一般对字符做加减运算没什么意义,但ASCII码字符是有意义的。比如大小写转换,大写A-Z的编号是65-90,小写a-z的编号是97-122,正好相差32,所以大写转小写只需加32,而小写转大写只需减32。加减运算的另一个应用是加密和解密,将字符进行某种可逆的数学运算可以做加解密。
char的位运算可以看作是对应整数的位运算,只是它是无符号数,也就是说,有符号右移>>和无符号右移>>>的结果是一样的。既然char本质上是整数,查看char的二进制表示,同样可以用Integer的方法

暂且把类看做是函数的容器. 我们将类看作自定义数据类型.

所谓自定义数据类型就是除了8种基本类型以外的其他类型,用于表示和处理基本类型以外的其他数据。一个数据类型由其包含的属性以及该类型可以进行的操作组成,属性又可以分为是类型本身具有的属性,还是一个具体实例具有的属性,同样,操作也可以分为是类型本身可以进行的操作,还是一个具体实例可以进行的操作。
这样,一个数据类型就主要由4部分组成:

  • 类型本身具有的属性,通过类变量体现。(也叫静态变量,static定义:只有一份,所有类对象共享之,可以通过类名.变量名直接访问)

  • 类型本身可以进行的操作,通过类方法体现。(也叫静态方法)

  • 类型实例具有的属性,通过实例变量体现。

  • 类型实例可以进行的操作,通过实例方法体现。

访问控制AC

  • 类方法只能访问类变量,不能访问实例变量,可以调用其他的类方法,不能调用实例方法。
  • 实例方法既能访问实例变量,也能访问类变量,既可以调用实例方法,也可以调用类方法。

不过,对于一个具体类型,每一个部分不一定都有,Arrays类就只有类方法。
类变量和实例变量都叫成员变量,也就是类的成员,类变量也叫静态变量或静态成员变量。类方法和实例方法都叫成员方法,也都是类的成员,类方法也叫静态方法。

对象创建

两块内存:一块存放实际内容,一块存放实际内容的位置。声明变量本身只会分配存放位置的内存空间,这块空间还没有指向任何实际内容。因为这种变量和数组变量本身不存储数据,而只是存储实际内容的位置,它们也都称为引用类型的变量。
p=new Point();创建了一个实例或对象,然后赋值给了Point类型的变量p,它至少做了3件事:
1)分配内存,以存储新对象的数据,对象数据包括这个对象的属性,具体包括其实例变量x和y。
2)给实例变量设置默认值,int类型默认值为0。
3) 调用构造方法

与方法内定义的局部变量不同,在创建对象的时候,所有的实例变量都会分配一个默认值,这与创建数组的时候是类似的,数值类型变量的默认值是0,boolean是false,char是\u0000”,引用类型变量都是null。null是一个特殊的值,表示不指向任何对象。

默认值

实例变量都会有一个默认值, 可以通过直接赋值/代码放进初始化代码块中用{}包围(静态变量也可以这样初始化). 新建对象时, 先调用这个初始化, 然后再执行构造方法中的代码.

构造方法

构造方法用于初始化对象, 对实例变量赋初始值,构造方法可以有多个(参数不同, 重载)。不同于一般方法,构造方法有一些特殊的地方:
1)名称是固定的,与类名相同。这也容易理解,靠这个用户和Java系统就都能容易地知道哪些是构造方法。
2)没有返回值,构造方法隐含的返回值就是实例本身

​ 也不能定义返回值类型:

class Hi {
    public int Hi() { //int需要去掉
        
    }
}

与普通方法一样,构造方法也可以重载。第二个构造方法是比较容易理解的,使用this对实例变量赋值。

调用

Point p=new Point(2,3);这个调用就可以将实例变量x和y的值设为2和3。分配内存,给实例变量设置默认值,调用构造方法。调用构造方法是new操作的一部分

默认&自定义构造方法

每个类都至少要有一个构造方法,在通过new创建对象的过程中会被调用。但构造方法如果没什么操作要做,可以省略。Java编译器会自动生成一个默认构造方法,也没有具体操作。

但一旦定义了构造方法,Java就不会再自动生成默认的,具体什么意思呢?在这个例子中,如果我们只定义了第二个构造方法(带参数的),则下面语句:Point p = new point();就会报错,因为找不到不带参数的构造方法。

生命周期

类加载进内存后,一般不会释放,直到程序结束。一般情况下,类只会加载一次所以静态变量在内存中只有一份。(如果类加载多次, 那么静态变量在每次类加载时都会创建一次)
当通过new创建一个对象的时候,对象产生,在内存中,会存储这个对象的实例变量值,每做new操作一次,就会产生一个对象,就会有一份独立的实例变量。
每个对象除了保存实例变量的值外,可以理解为还保存着对应类型即类的地址,这样,通过对象能知道它的类,访问到类的变量和方法代码。
实例方法可以理解为一个静态方法,只是多了一个参数this。通过对象调用方法,可以理解为就是调用这个静态方法,并将对象作为参数传给this。
对象的释放是被Java用垃圾回收机制管理的,大部分情况下,我们不用太操心,当对象不再被使用的时候会被自动释放。

import

类似文件夹, 访问控制解决命名冲突, 在java中组织类和接口的方式为包. 完全限定名: 带有完整包名的类名

包的引入.*不能递归, 只引入当前层, 子包无关

可见性范围从小到大是:private<默认(包)<protected<public。

jar包

在Java中,编译后的一个或多个包的Java class文件可以打包为一个文件,Java中打包命令为jar,打包后的文件扩展名为jar,一般称之为jar包。

hello.jar就是jar包,jar包其实就是一个压缩文件,可以使用解压缩工具打开。
Java类库、第三方类库都是以jar包形式提供的。如何使用jar包呢?将其加入类路径(classpath)中即可。类路径是什么呢?我们下面来看。

classpath与程序的编译链接

从Java源代码到运行的程序,有编译和链接两个步骤。

  • 编译是将源代码文件变成扩展名是.class的一种字节码,这个工作一般是由javac命令完成的。
  • 链接是在运行时动态执行的,class文件不能直接运行,运行的是Java虚拟机,虚拟机听起来比较抽象,执行的就是Java命令,这个命令解析.class文件,转换为机器能识别的二进制代码,然后运行。所谓链接就是根据引用到的类加载相应的字节码并执行。

Java编译和运行时,都需要以参数指定一个classpath,即类路径。类路径可以有多个,对于直接的class文件,路径是class文件的根目录;对于jar包,路径是jar包的完整名称(包括路径和jar包名)。
在Java源代码编译时,Java编译器会确定引用的每个类完全限定名确定的方式是根据import语句和classpath。如果导入的是完全限定类名,则可以直接比较并确定。如果是模糊导入(import带.*),则根据classpath找对应包,再在包下寻找是否有对应的类。如果多个模糊导入的包下都有同样的类名,则Java会提示编译错误,此时应该明确指定导入哪个类。
Java运行时,会根据类的完全限定名寻找并加载类,寻找的方式就是在类路径中寻找,如果是class文件的根目录,则直接查看是否有对应的子目录及文件,如果是jar文件,则首先在内存中解压文件,然后再查看是否有对应的类。

!!类加载–反复看 对应书中的4.3 继承实现的原理

public class Base{
    public static int s;//静态变量, 对象创建过程不能赋值--和对象无关; 变量操作:通过创建Base类对象
    private int a;//实例变量
    static{
        System.out.print1n("基类静态代码块,s:"+s);
        s=1}
    {
        System.out.println("基类实例代码块,a:"+a);
        a=1}
    public Base(){
        System.out.println("基类构造方法,a:"+a);
        a=2}
    protected void step(){
        System.out.printin("base s:"+s +",a:"+a);
    }
    public void action(){
        System.out.println("start");
        step();
        System.out.print1n("end");
    }
}

public class Child extends Base{
	public static int s;
	private int a;
	static{
		System.out.print1n("子类静态代码块,s:"+s);
		s=10}
	{
		System.out.print1n("子类实例代码块,a:"+a);
		a=10}
	public Child(){
		System.out.print1n("子类构造方法,a:"+a);a=20}
	protected void step(){
		System.out.printin("childs:"+s+",a:"+a);
	}
}

public static void main(String[] args){ 
    System.out.println("----new Child()"); 
    Child c=new Child(); 
    System.out.println("\n----c. action()"); 
    c.action(); 
    Base b=c; 
    System.out.println("\n----b. action()"); 
    b.action(); 
    System.out.println("\n----b.s:"+b.s); 
    System.out.println("\n----c.s:"+c.s);
}
/*执行结果
----new Child()
基类静态代码块,s:0
子类静态代码块,s:0
基类实例代码块,a:0
基类构造方法,a:1
子类实例代码块,a:0
子类构造方法,a:10
-—-c.action()
start 
childs:10,a:20
end
-—-b.action()
start 
childs:10,a:20
end
---b.s:1
----c.s:10
*/

类加载过程包括:

  • 分配内存保存类的信息;
  • 给类变量赋默认值;
  • 加载父类;(注意这一步是递归的, 直到加载到Object类)
  • 设置父子关系;
  • 执行类初始化代码。先执行父类的,再执行子类的。

对象创建

在类加载之后,new Child()就是创建Child对象,创建对象过程包括:
1)分配内存;
2)对所有实例变量赋默认值;
3)执行实例初始化代码。
分配的内存包括本类和所有父类的实例变量,但不包括任何静态变量。实例初始化代码的执行从父类开始,再执行子类的。但在任何类执行初始化代码之前,所有实例变量都已设置完默认值。
每个对象除了保存类的实例变量之外,还保存着实际类信息的引用。

方法调用的过程–动态绑定

我们先来看c.action();,这句代码的执行过程:
1)查看c的对象类型,找到Child类型,在Child类型中找action方法,发现没有,到父类中寻找;
2)在父类Base中找到了方法action,开始执行action方法;
3)action先输出了start,然后发现需要调用step()方法,就从Child类型开始寻找step()方法;
4)在Child类型中找到了step()方法,执行Child中的step()方法,执行完后返回action方法;
5)继续执行action方法,输出end。
寻找要执行的实例方法的时候,是从对象的实际类型Base b = new Child(); b的实际类型为Child信息开始查找的,找不到的时候,再查找父类类型信息。(逻辑: 子类可以重新实现方法对父类的同名方法进行覆盖)
我们来看b.action(),这句代码的输出和c.action()是一样的,这称为动态绑定,而动态绑定实现的机制就是根据对象的实际类型查找要执行的方法子类型中找不到的时候再查找父类。这里,因为b和c指向相同的对象,所以执行结果是一样的。
如果继承的层次比较深,要调用的方法位于比较上层的父类,则调用的效率是比较低的,因为每次调用都要进行很多次查找。大多数系统使用一种称为虚方法表的方法来优化调用的效率。
所谓虚方法表,就是在类加载的时候为每个类创建一个表,记录该类的对象所有动态绑定的方法(包括父类的方法)及其地址,但一个方法只有一条记录,子类重写了父类方法后只会保留子类的。对于本例来说,Child和Base的虚方法表如图4-5所示。

变量访问的过程–静态绑定

对变量的访问是静态绑定的,无论是类变量还是实例变量。代码中演示的是类变量:b.s和c.s,通过对象访问类变量,系统会转换为直接访问类变量Base.s和Child.s。

谨慎使用继承

继承破坏封装: 继承可能破坏封装是因为子类和父类之间可能存在着实现细节的依赖。耦合.

如果子类不知道基类方法的实现细节,它就不能正确地进行扩展。 子类父类不能随意修改.

避免继承

避免继承,有三种方法:

  • 使用final关键字; --final方法不能被重写,final类不能被继承, 让父类可以随性修改, 而其对子类申明的功能不变
  • 优先使用组合而非继承;使用组合可以抵挡父类变化对子类的影响,从而保护子类,应该优先使用组合。
  • 使用接口。
合理的使用继承

见book.

类的拓展

接口interface

只关心能力, 不关心类型实现(你是什么类无所谓, 只要能完成接口定义的功能, 反应了对象以及对象操作的本质)

解耦合: 双方对象并不存在相互依赖关系, 只是通过接口间接通信交互

实际应用: 使用接口定义功能, 同时使用一个与之关联的抽象类实现默认功能.

面向接口编程是重要的思想方法.

定义

Java8之前,接口内不能实现方法。接口方法不需要加修饰符,加与不加相当于都是public abstract。

需要至少两个参与者: 一个实现接口, 一个使用接口.

实现

类可以实现接口,表示类的对象具有接口所表示的能力

通过关键字implements来使用接口, 通过注解@Override一个同名方法来实现接口定义的功能(覆盖抽象类的默认方法实现)

public class Point implements MyComparable{
    ...
    @Override
    public int compareTo(Object obj){
        ...
    }
}

使用

对象只能通过类来创建, 接口不能new.

如果一个类型实现了多个接口, 那么可以把这个类型的对象赋给其中任一接口类型的变量(如下所示, Point实现了MyComparable接口.)

MyComparable p = new Point();
//MyComparable为接口类型, Point为实现.
//p只能调用MyCompareble的方法, 执行时执行Point中的实现代码.

面向接口编程: 上述例子中, p只能调用MyCompareble的方法, 执行时执行Point中的实现代码. 我们可以只关心接口需要的功能, 而不需要知道具体的类型!

变量

在接口中定义变量, 修饰符使用 public static final 不写编译器也默认加. 变量通过接口名.变量名的方式使用.

继承

接口可以继承多个父接口.

判断一个对象是否实现了某个接口

和类一样, 使用instanceof关键字来判断.

if(p instanceof MyComparable)

用接口+组合替代继承

接口: 统一处理, 组合: 复用代码

继承至少有两个好处:一个是复用代码;另一个是利用多态和动态绑定统一处理多种不同子类的对象。使用组合替代继承,可以复用代码,但不能统一处理。使用接口替代继承,针对接口编程,可以实现统一处理不同类型的对象,但接口没有代码实现,无法复用代码。将组合和接口结合起来替代继承,就既可以统一处理,又可以复用代码了。

默认方法和静态方法

在java 8之前,接口中的方法都是抽象方法,都没有实现体,Java8允许在接口中定义两类新方法:静态方法和默认方法,它们有实现体,比如:

public interface IDemo{
    void he11o();
    public static void test(){ //静态方法 通过IDemo.test()调用
    	System.out.print1n("hel1o");
    }
    default void hi(){ //默认方法, 可覆盖
 	   System.out.print1n("hi");
    }
}

test()就是一个静态方法,可以通过IDemo.test()调用。在接口不能定义静态方法之前,相关的静态方法往往定义在单独的类中,比如,JavaAPl中,Collection接口有一个对应的单独的类Collections,在Java8中,就可以直接写在接口中了,比如Comparator接口就定义了多个静态方法。
hi()是一个默认方法,用关键字default表示。默认方法与抽象方法都是接口的方法,不同在于,默认方法有默认的实现,实现类可以改变它的实现,也可以不改变。引入默认方法主要是函数式数据处理的需求,是为了便于给接口增加功能。关于函数式数据处理,会在第26章介绍。

抽象类

本节介绍了抽象类,相对于具体类,它用于表达抽象概念,虽然从语法上抽象类不是必需的,但它能使程序更为清晰,可以减少误用抽象类和接口经常相互配合,接口定义能力,而抽象类提供默认实现,方便子类实现接口。

抽象方法&使用规定

抽象方法只有声明没有实现, 只有子类才知道如何实现的方法, 一般定义为抽象方法Shape()为抽象方法, 具体的Square(), Triangle()等为子类的具体方法.

定义了抽象方法的类必须被声明为抽象类,不过,抽象类可以没有抽象方法。抽象类和具体类一样,可以定义具体方法、实例变量等,它和具体类的核心区别是,抽象类不能创建对象, 要创建对象必须使用它的具体子类.

抽象类不能创建对象,要创建对象,必须使用它的具体子类。一个类在继承抽象类后**,必须实现抽象类中定义的所有抽象方法**,除非它自己也声明为抽象类。

与接口的配合

一个接口往往有一个对应的抽象类.

与接口的相似与不同

抽象类和接口有类似之处:都不能用于创建对象,接口中的方法其实都是抽象方法。如果抽象类中只定义了抽象方法,那抽象类和接口就更像了。但抽象类和接口根本上是不同的,接口中不能定义实例变量,而抽象类可以,一个类可以实现多个接口,但只能继承一个类

配合使用

抽象类和接口是配合而非替代关系,它们经常一起使用,接口声明能力,抽象类提供默认实现,实现全部或部分方法,一个接口经常有一个对应的抽象类。

对于需要实现接口的具体类而言,有两个选择:一个是实现接口,自己实现全部方法;另一个则是继承抽象类,然后根据需要重写方法
继承的好处是复用代码,只重写需要的部分即可,需要编写的代码比较少,容易实现。不过,如果这个具体类已经有父类了,那就只能选择实现接口了。

!内部类 反复看

成员静态, 方法匿名.

内部类是一个编译器概念, 虚拟机不知道这个概念: 每个内部类都被编译成一个独立的类(都可以等价替换为一个独立的类). 既然如此为何使用? 方便的访问外部类的私有变量, 更好的封装隐藏, 代码简洁.

方法内部类把变量修改为单值数组的操作, 可以理解为变相的指针操作

静态内部类

位置放在类的内部, 几乎相当于一个独立的类, 只是能访问外部类的静态变量(编译器实现: 自动为Outer生成一个非私有访问方法access$0,它返回这个私有静态变量shared。).

public的静态内部类可以通过Outer.Inner in= new Outer.Inner直接创建而不依赖外部类.

如果一个类与外部类关系密切,且不依赖于外部类实例,则可以考虑定义为静态内部类。

比如,一个类内部,如果既要计算最大值,又要计算最小值,可以在一次遍历中将最大值和最小值都计算出来,但怎么返回呢?可以定义一个类Pair,包括最大值和最小值,但Pair这个名字太普遍,而且它主要是类内部使用的,就可以定义为一个静态内部类。

成员内部类

在静态内部类的基础上, 增加可以访问外部类的实例方法/变量(如果内外重名, 通过外部类.this.xxx来访问)

不独立: 总是和外部类的一个对象相连. 必须通过创建外部类对象; 外部类对象.new 内部类来创建, 不能像静态内部类那样, 直接Outer.Inner in= new Outer.Inner创建.

Outer out=new Outer();
Outer.Inner inner=out.new Inner();

不能创建静态变量, 方法 (final变量除外, 其等同于常量) : 成员匿名方法内部类都不可以. 这是java的规定. 从实现上来说是可以定义的, 为什么规定不允许定义: 总是和外部类关联使用的, 直接定义在外部类中即可, 在内部类中定义的意义不大, 确实有需要的静态变量方法也可以移到外部类中去.

使用

成员内部类有哪些应用场景呢?如果内部类与外部类关系密切,需要访问外部类的实例变量或方法,则可以考虑定义为成员内部类。外部类的一些方法的返回值可能是某个接口,为了返回这个接口,外部类方法可能使用内部类实现这个接口,这个内部类可以被设为private,对外完全隐藏。

常用基础类

包装类

自动拆箱装箱是编译器功能: 自动替换为调用相应的valueOf/xxxValue方法

重写Object类的equals方法, hashCode方法(各类hashcode方法见book p140), 实现Comparable接口(只有一个方法compare返回0,+1,-1. 对于Boolean, true>false)

浮点数0.01f和0.1f*0.1f是不相等的

parseInt(String) 返回基本类型int; valueOf(String)返回包装类Integer对象.

6种数值类型有共同的父类Number(抽象类), 通过Number的方法, 包装类的实例可以返回任一类型的基本数值类型.

integer的reverse方法:7.1.3 作为例子, 使用移位和多位同时操作来高效运算.

线程安全:不可变性

包装类的实例对象一旦创建就无法改变

实现:

·所有包装类都声明为了final,不能被继承。
·内部基本类型值是私有的,且声明为了inal。
·没有定义setter方法。

String

java8之前用char数组存string, java9用byte数组表示string(如果全是ASCII码, 可以只用1个字节, 省内存)

String name1=“老马说编程";
String name2=“老马说编程";
System.out.println(name1==name2);//true

String name1=new String"老马说编程");
String name2=new String"老马说编程");//new方法创建了两个不同的对象
System.out.print1n(name1==name2); //false ==直接比内存地址
System.out.print1n(name1.equals(name2));//true String类的equals方法重写了Object的equals方法

**!看:**java中hashcode的实现方法

string的compareTo方法?

StringBuilder

如果字符串修改操作比较频繁,应该采用StringBuilder和StringBuffer类,这两个类的方法基本是完全一样的,它们的实现代码也几乎一样,唯一的不同就在于StringBuffer类是线程安全的,而StringBuilder类不是。

线程安全是有成本的,影响性能,而字符串对象及操作大部分情况下不存在线程安全问题,适合使用String-Builder类。

String的+和+=是一个编译器支持, 生成StringBuilder, +和+=转化为append.

在复杂情况(如循环)下String的运算符可能出错(java编译器没有那么智能, 可能生成过多Builder), 此时应该直接使用StringBuilder

Arrays

Arrays.sort()方法, 所有基本类型的数组都可用,boolean除外

实现Comparable接口的对象类型数组都可以排序.

char, String数组的排序以ASCII码(大写一定排在小写之前)

自定义sort: 用匿名类实现Comparable接口

String[] arr={"hello","world","Break","abc"}; 
Arrays. sort(arr, new Comparator<String>(){
	@verride 
	public int compare(String ol, String o2){
		return o2.compareToIgnoreCase(o1);//忽略大小写的排序
	}
}); 
System.out.printin(Arrays.toString(arr));

sort方法实现, 基础类型用的特殊快排, 对象类型用的优化过的归并排序, 数组规模小的时候直接使用插入排序.

Arrays.binarySearch()方法

针对已经排序过的对象数组可用

int[] arr={3,5,7,13,21}; 
System.out.println(Arrays.binarySearch(arr,13));

找到返回序号, 没找到返回负数.

容器

容器类: 容纳并且管理多项数据的类.

java.util.collection是接口, java.util.collections是其包装类, 有很多静态方法, 不可实例化.

TreeMap

!失败, 根据书上的代码, MyEclipse在重写的方法上报错. 重写构造方法实现逆序

Map<String, String> map= new TreeMap<>(new Comparator<String>(){
    @Override
    public int compare(String s1, String s2){
    	return s2.compareTo(s1);
    }
});

LinkedHashMap

利用LinkedHashMap实现LRU缓存.

import java.util.LinkedHashMap;
public class LRUCache {
    private LinkedHashMap<Integer, Integer> map;
    private final int CAPACITY;
    public LRUCache(int capacity) {
        CAPACITY = capacity;
        map = new LinkedHashMap<Integer, Integer>(capacity, 0.75f, true){
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > CAPACITY;
            }
        };
    }
    public int get(int key) {
        return map.getOrDefault(key, -1);
    }
    public void put(int key, int value) {
        map.put(key, value);
    }
}

Synchronized

Synchronized保护的是对象而不是代码. 在保护变量时, 需要在所有访问该变量的方法上加上Synchronized.

进程定义了一个执行环境, 有私有的地址空间, 一个句柄表, 以及一个安全环境; 线程是一个控制流, 有自己的调用栈, 记录执行历史.

猜你喜欢

转载自blog.csdn.net/whichard/article/details/87863526