Java基础复习笔记(根据JavaGuide和面试题pdf总结)

393 阅读17分钟

复习笔记 复习根据一些教程还有面试题总结的,方便查看

一、基础语法

1. 关键字

image.png default 这个关键字很特殊,既属于程序控制(switch),也属于方法和变量修饰符(默认方法),还属于访问控制(默认修饰符)。 :::danger ⚠️ 注意 :true, false, null 看起来像关键字,但实际上他们是字面值!! :::

2. 数据类型

image.png

  • char:2个字节,表示方式:单引号'' 也可以是数字(字符的ASCII码)
  • boolean未规定字节数
  • long后缀L,float后缀F
  1. +=易错题: :::

short s1 = 1; s1 = s1 + 1; 对吗? short s1 = 1; s1 += 1; 对吗?

第一个:s1+1,由于1默认是int,则s1+1是int,int赋值给short,编译错误 第二个:s1 += 1暗含强制类型转换,转为+=左边的类型,==> s1 = (short)(s1 + 1)

2. ++、--运算符

当 b = ++a 时,先自增(自己增加 1),再赋值(赋值给 b);

当 b = a++ 时,先赋值(赋值给 b),再自增(自己增加 1)。

int i = 1; 
i = i++; 
System.out.println(i);

// 对于JVM而言,它对自增运算的处理,是会先定义一个临时变量来接收i的值,然后
// 进行自增运算,最后又将临时变量赋给了值为2的i,所以最后的结果为1。
  1. 为什么浮点数运算的时候会有精度丢失的风险?
float a = 2.0f - 1.9f;
float b = 1.8f - 1.7f;
System.out.println(a);// 0.100000024
System.out.println(b);// 0.099999905
System.out.println(a == b);// false

和计算机保存浮点数的机制有很大关系。计算机在表示一个数字时,宽度是有限的,无限循环的小数存储在计算机时,只能被截断,所以就会导致小数精度发生损失的情况。 十进制下的 0.2 没办法精确转换成二进制小数:

// 0.2 转换为二进制数的过程为,不断乘以 2,直到不存在小数为止,
// 在这个计算过程中,得到的整数部分从上到下排列就是二进制的结果。
0.2 * 2 = 0.4 -> 0
0.4 * 2 = 0.8 -> 0
0.8 * 2 = 1.6 -> 1
0.6 * 2 = 1.2 -> 1
0.2 * 2 = 0.4 -> 0(发生循环)
...
  1. 如何解决2

BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。

3. 包装类型

  1. 包装类型和基本类型的区别: | | 包装类型 | 基本类型 | | --- | --- | --- | | 默认值 | 属于引用类型,默认null | 不为null | | 泛型支持 | 支持 | 不支持 | | 变量位置 | 堆中 |
  2. 作为局部变量:栈中
  3. 作为成员变量:堆中 |

⚠️ 注意 : 基本数据类型存放在栈中是一个常见的误区! 基本数据类型的成员变量如果没有被 static 修饰的话(不建议这么使用,应该要使用基本数据类型对应的包装类型),就存放在堆中。 :::

  1. 自动装箱、自动拆箱
Integer i = 10;  //装箱 ==> Integer i = Integer.valueOf(10)
int n = i;   //拆箱	==> int n = i.intValue()

如何判断int和Integer的==?

二者在做==运算时,Integer会自动拆箱为int类型,然后再进行比较。届时,如果两个int值相等则返回true,否则就返回false。

如何对Integer和Double类型判断相等?

整数、浮点类型的包装类,都继承于Number类型,而Number类型分别定义了将数字转换为byte、short、int、long、float、double的方法。所以,可以将Integer、Double先转为转换为相同的基本数据类型(如double),然后使用==进行比较。

Integer i = 100;
Double d = 100.00;
System.out.println(i.doubleValue() == d.doubleValue());
  1. 缓存机制

    Byte,Short,Integer,Long 这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True or False。 浮点类型没有缓存!

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];//在范围内,直接返回cache数组中的对象
    return new Integer(i);
}
private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static {
        // high value may be configured by property
        int h = 127;
    }
}

所有整型包装类对象之间值的比较,全部使用 equals 方法比较!! image.png

4. ==、equals()、hashCode()

  1. ==与equals()
  • ==:基本类型的值、引用类型的值(是一个地址值)是否相等
  • equals():引用类型都有equals()方法,一般重写 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
  1. equals()与hashCode()
    1. hashCode()是啥?

hashCode() 在散列表中才有用,在其它情况下没用!! 原方法是根据对象地址返回一个哈希码, 和 equals()都是用于比较两个对象是否相等(但hashCode不严格)

  1. 为什么提供两个呢?

因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)! 但是同一个 hashCode 可能有多个对象,所以它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。

  1. 为什么两个对象的hashCode 值相等并不代表两个对象就相等?

hashCode() 使用哈希算法将对象的地址值映射为一个哈希码,但不同的地址可能映射为一个哈希码。(所谓哈希碰撞也就是指的是不同的对象得到相同的 hashCode )。

  1. 为什么重写 equals() 时必须重写 hashCode() 方法?
  • 若不创建类的哈希集合,则没有任何关系;
  • 创建类的哈希集合,有关系:

因为equals()对象相等时,hashCode()也必须相等; equals()若重写为判断对象的内容是否相等,hashCode()不重写就是判断地址对应的哈希码,导致equals()相等,但hashCode()不相等; 例如:若Person类只重写了equals(),而没有重写hashCode():往HashSet里面添加Person类对象时,两个Person对象的内容一样,但哈希码不一样。

5. 值传递

在方法调用时,调用方传递的是实参;被调用的方法的参数是形参

String hello = "Hello!";

sayHello(hello); // hello 为实参

void sayHello(String str) { // str 为形参
    System.out.println(str);
}

调用方法时,传递参数的两种方式:

  • 值传递:被调方法根据实参的值进行拷贝(引用拷贝),方法内部使用的是拷贝的变量
  • 引用传递:被调方法直接使用实参值,不拷贝

C++对应基本类型使用值传递,引用类型使用引用传递; Java对于基本类型和引用类型都使用值传递;

二、对象

1. 对象实体与对象引用

Object o = new Object();
  • 使用构造方法创建对象,和普通方法区别:前面+new
  • new Object():new的是对象实例,在堆内存
  • Object o:对象引用指向该实例,在栈内存
  • 对象相等:堆内存中的对象内容相等
  • 引用相等:栈内存中指向同一个对象

2. 多态

表示一个对象具有多种的状态,具体表现为父类的引用指向子类的实例 相同类型的变量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。 无须任何类型转换,或者被称为向上转型,向上转型由系统自动完成。 特点

  • 引用和对象之间关系:继承类、实现接口
  • 多态进行方法调用,在运行时才能确定
  • 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法

扩展阅读

多态可以提高程序的可扩展性,在设计程序时让代码更加简洁而优雅。 例如我要设计一个司机类,他可以开轿车、巴士、卡车等等,示例代码如下: class Driver {
void drive(Car car) { ... }
void drive(Bus bus) { ... }
void drive(Truck truck) { ... } } 在设计上述代码时,我已采用了重载机制,将方法名进行了统一。这样在进行调用时,无论要开什么交通工具,都是通过driver.drive(obj)这样的方式来调用,对调用者足够的友好。 但对于程序的开发者来说,这显得繁琐,因为实际上这个司机可以驾驶更多的交通工具。当系统需要为这个司机增加车型时,开发者就需要相应的增加driver方法,类似的代码会堆积的越来越多,显得臃肿。 采用多态的方式来设计上述程序,就会变得简洁很多。我们可以为所有的交通工具定义一个父类Vehicle,然后按照如下的方式设计drive方法。调用时,我们可以传入Vehicle类型的实例,也可以传入任意的Vehicle子类型的实例,对于调用者来说一样的方便,但对于开发者来说,代码却变得十分的简洁了。 class Driver {
void drive(Vehicle vehicle) { ... } }

3. 成员属性和局部变量、静态方法和实例方法

成员属性局部变量
语法形式
  1. 类的成员
  2. 使用访问修饰符、static |
  3. 方法内(或代码块)或方法参数
  4. 不能使用访问修饰符、static(可以使用final) | | 默认值 | 使用变量类型默认值(final必须赋值) | 无 | | 存储方式 |
  5. static的类变量:方法区中
  6. 实例变量:所处对象的堆中 | 栈中,作用的范围(方法调用)结束,栈空间会自动的释放 |

静态方法:不能访问类的非静态成员变量和方法。(main方法里只能访问静态的成员) 实例方法:可以访问类的所有成员变量和方法。

4. 方法的重载和重写

重载:同一个方法根据输入数据的不同,做出不同的处理 重写:子类继承父类的相同方法,输入数据一样,但要做出有别于父类的响应时,你就要覆盖父类方法

重载发生在一个类中,同名的方法如果有不同的参数列表(参数类型、个数、顺序)则视为重载;

  • 编译期间确定
  • 可以重载所有方法

重写发生在运行期,是子类对父类的允许访问的方法的实现过程进行重新编写。

  • 方法名、参数列表必须相同,子类方法返回值类型应比父类方法返回值类型更小或相等(void或基本类型),抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。(里氏代换原则)
  • 如果父类方法访问修饰符为 private/final/static 则子类就不能重写该方法,但是被 static 修饰的方法能够被再次声明。
  • 构造方法无法被重写。 (否则子类的重写的构造方法名称与类名不同)

5. String对象

  1. String不可变(String对象或字面量)
String s1 = 'lzh';
s1 = 'jfc';

创建s1指向字符串 'lzh',看似可变,但其实是修改引用:又创建了字符串 'jfc',只不过修改s1指向新字符串,整个过程创建了两个字符串

  1. 为什么不可变呢?
  • 字符串在 Java 中广泛使用,如果字符串是可变的,容易被篡改,无法保证字符串进行操作时,是安全的
  • 在多线程中,只有不变的对象和值是线程安全的,可以在多个线程中共享数据。由于 String 天然的不可变,当一个线程”修改“了字符串的值,只会产生一个新的字符串对象,不会对其他线程的访问产生副作用,访问的都是同样的字符串数据,不需要任何同步操作。
  • 字符串大量应用在散列集合,在散列集合中,存放元素都要根据对象的 hashCode() 方法来确定元素的位置。由于字符串 hashcode 属性不会变更,保证了唯一性,使得类似 HashMap,HashSet 等容器才能实现相应的缓存功能。由于 String 的不可变,避免重复计算 hashcode,只要使用缓存的 hashcode 即可,这样一来大大提高了在散列集合中使用 String 对象的性能。
  • 当字符串不可变时,字符串常量池才有意义。字符串常量池的出现,可以减少创建相同字面量的字符串,让不同的引用指向池中同一个字符串,为运行时节约很多的堆内存。若字符串可变,字符串常量池失去意义,基于常量池的 String.intern() 方法也失效,每次创建新的字符串将在堆内开辟出新的空间,占据更多的内存。
  1. 如何不可变?
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char value[];
	...
}
  • final 修饰的value[],那么该数组引用不可变 + String内部没有提供修改该数组的方法;

  • String 类被 final 修饰导致其不能被继承,进而避免了子类破坏 String 不可变;

    1. 如何可变?

使用StringBuilder/StringBuffer 每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。 StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。

通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、setLength()等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法将其转换为一个String对象。 StringBuffer是线程安全的,而StringBuilder是非线程安全的,所以StringBuilder性能略高。一般情况下,要创建一个内容可变的字符串,建议优先考虑StringBuilder类。

  1. 字符串拼接
    • “+”和“+=”是专门为 String 类重载过的运算符,也是 Java 中仅有的两个重载过的运算符
    • 拼接字面量,编译器会将其直接优化为一个完整的字符串
    • 拼接包含变量,编译器采用StringBuilder对其进行优化,通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象 。不过,在循环内使用“+”进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。相当于执行了 new StringBuilder().append(str),所以此时效率很低。
String[] arr = {"he", "llo", "world"};
String s = "";
for (int i = 0; i < arr.length; i++) {
    s += arr[i];
}
System.out.println(s);
  • 字符串使用 final 关键字声明之后,可以让编译器当做常量来处理
final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true

被 final 关键字修改之后的 String 会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。 如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。

  • StringBuilder
    • StringBuilder/StringBuffer都有字符串缓冲区,缓冲区的容量在创建对象时确定,并且默认为16。当拼接的字符串超过缓冲区的容量时,会触发缓冲区的扩容机制,即缓冲区加倍。
    • 缓冲区频繁的扩容会降低拼接的性能,所以如果能提前预估最终字符串的长度,则建议在创建可变字符串对象时,放弃使用默认的容量,可以指定缓冲区的容量为预估的字符串的长度。
String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
    s.append(value);
}
System.out.println(s);
  • concat()
    • concat方法的拼接逻辑是,先创建一个足以容纳待拼接的两个字符串的字节数组,然后先后将两个字符串拼到这个数组里,最后将此数组转换为字符串。
    • 在拼接大量字符串的时候,concat方法的效率低于StringBuilder。但是只拼接2个字符串时,concat方法的效率要优于StringBuilder。并且这种拼接方式代码简洁,所以只拼2个字符串时建议优先选择concat方法。
  1. 字符串常量池

字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。 存放字面量对象

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
  • String s = "abc"; ,说一下这个过程会创建什么,放在哪里?

JVM会使用常量池来管理字符串直接量。在执行这句话时,JVM会先检查常量池中是否已经存有"abc",若没有则将"abc"存入常量池,否则就复用常量池中已有的"abc",将其引用赋值给变量a。

  • new String("abc") 是去了哪里,仅仅是在堆里面吗?

在执行这句话时,JVM会先使用常量池来管理字符串直接量,即将"abc"存入常量池。然后再创建一个新的String对象,这个对象会被保存在堆内存中。并且,堆中对象的数据会指向常量池中的直接量。 会创建 1 或 2 个字符串对象。

  • String.intern()方法

是一个 native(本地)方法,其作用是将指定的字符串对象的引用保存在字符串常量池中,可以简单分为两种情况:

  • 如果字符串常量池中保存了对应的字符串对象的引用,就直接返回该引用。
  • 如果字符串常量池中没有保存了对应的字符串对象的引用,那就在常量池中创建一个指向该字符串对象的引用并返回。

6. 对象的拷贝

引用拷贝、浅拷贝、深拷贝

  1. 引用拷贝:栈内存中的两个引用指向了堆内存中同一个实例,属于引用相等
  2. 浅拷贝:在堆中创建一个新对象,但新对象内的引用属性和原对象共用
  3. 深拷贝:在堆中创建完全一样的新对象(包括内部的引用属性)

7. 对象的序列化

序列化就是把Java对象转为二进制流,方便存储和传输。 所以反序列化就是把二进制流恢复成对象。 image.png 序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。 如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流。反过来的话,就是将二进制流转换成应用层的用户数据。这不就对应的是序列化和反序列化么? 因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。

常见序列化协议有哪些?

  • jdk自带的序列化
    • 实现 java.io.Serializable接口, 序列化号 serialVersionUID 属于版本控制的作用。反序列化时,会检查 serialVersionUID 是否和当前类的 serialVersionUID 一致。如果 serialVersionUID 不一致则会抛出异常。如果不手动指定,那么编译器会动态生成默认的 serialVersionUID。
    • static 修饰的变量是静态变量,位于方法区,本身是不会被序列化的。 static 变量是属于类的而不是对象。你反序列之后,static 变量的值就像是默认赋予给了对象一样,看着就像是 static 变量被序列化,实际只是假象罢了。
    • transient 关键字的作用是:阻止实例中那些用此关键字修饰的的变量序列化;反序列化后变量值将会被置成类型的默认值。例如,如果是修饰 int 类型,那么反序列后结果就是 0。
  • json序列化

使用jackson包,通过ObjectMapper类来进行一些操作,比如将对象转化为byte数组或者将json串转化为对象。

  • Kryo、Protobuf

三、其他

1. 泛型

Java集合取出该对象时,对象的编译类型就变成了Object类型(其运行时类型没变)。 因为集合的设计者不知道我们会用集合来保存什么类型的对象,所以他们把集合设计成能保存任何类型的对象,只要求具有很好的通用性。但这样做带来如下两个问题:

  • 集合对元素类型没有任何限制,这样可能引发一些问题。例如,想创建一个只能保存Dog对象的集合,但程序也可以轻易地将Cat对象“丢”进去,所以可能引发异常。
  • 由于把对象“丢进”集合时,集合丢失了对象的状态信息,只知道它盛装的是Object,因此取出集合元素后通常还需要进行强制类型转换。这种强制类型转换既增加了编程的复杂度,也可能引发ClassCastException异常。

从Java 5开始,Java引入了“参数化类型”的概念,允许程序在创建集合时指定集合元素的类型,Java的参数化类型被称为泛型(Generic)。例如 List,表明该List只能保存字符串类型的对象。 有了泛型以后,程序再也不能“不小心”地把其他对象“丢进”集合中。而且程序更加简洁,集合自动记住所有集合元素的数据类型,从而无须对集合元素进行强制类型转换。

  1. 使用泛型

泛型类、泛型接口、泛型方法。

image.png 静态泛型方法:方法参数含有泛型时,要在返回值前加泛型

public static void f( E[] arr ) 称为静态泛型方法 java 中泛型只是一个占位符,必须在传递类型后才能使用。创建泛型类的对象时通过指定泛型来传递泛型 静态方法的加载先于类的实例化,所以静态泛型方法是没有办法使用类上声明的泛型的。静态泛型方法只能使用自己声明的泛型 ,这样在调用静态方法时,通过方法参数传递泛型。 和类泛型不同,可以使用或其他

  1. 泛型擦除

Java 的泛型是伪泛型,这是因为 Java 在编译期间,所有的类型信息都会被擦掉。 也就是说,在运行的时候是没有泛型的

LinkedList<Cat> cats = new LinkedList<Cat>(); 
LinkedList list = cats; // 注意我在这里把范型去掉了,但是list和 cats是同一个链表! 泛型擦除
list.add(new Dog()); // 完全没问题!

范型只存在于源码里,编译的时候静态地检查一下范型类型是否正确,运行时就不检查了。 上面这段代码在JRE(Java运行环境)看来和下面这段没区别:

LinkedList cats = new LinkedList(); // 注意:没有范型! 
LinkedList list = cats; list.add(new Dog());

为什么要类型擦除呢? 主要是为了向下兼容,因为JDK5之前是没有泛型的,为了让JVM保持向下兼容,就出了类型擦除这个策略。

List = List 编译错误! 因为List不是List的子类!!泛型不允许这样赋值,编译器会报错。 Number[] = Integer[] 编译正确! Integer[]是Number[]的子类型

Java的数组支持型变,但Java集合并不支持型变。 Java泛型的设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时ClassCastException异常。