Java 基础
Java 数据类型
Java 包含两种数据类型:8 种基本数据类型和 3 种引用数据类型。
Java 基本数据类型
Java 中有 8 种基本数据类型,分别为:
-
6 种数字类型:
-
4 种整数型:
byte
、short
、int
、long
-
2 种浮点型:
float
、double
-
-
1 种字符类型:
char
-
1 种布尔型:
boolean
这 8 种基本数据类型的默认值以及所占空间的大小如下:
基本类型 | 位数 | 字节 | 默认值 | 取值范围 |
---|---|---|---|---|
byte |
8 | 1 | 0 | \([-2^{7}, 2^{7} - 1]\), 即 -128 ~ 127 |
short |
16 | 2 | 0 | \([-2^{15}, 2^{15} - 1]\), 即 -32768 ~ 32767 |
int |
32 | 4 | 0 | \([-2^{31} ,2^{31} - 1]\), 即 -2147483648 ~ 2147483647 |
long |
64 | 8 | 0L | \([-2^{63}, 2^{63} - 1]\), 即 -9223372036854775808 ~ 9223372036854775807 |
char |
16 | 2 | u0000 |
\([0, 2^{16} - 1]\), 即 0 ~ 65535 |
float |
32 | 4 | 0.0f |
1.4E-45 ~ 3.4028235E38 |
double |
64 | 8 | 0.0d |
4.9E-324 ~ 1.7976931348623157E308 |
boolean |
1 | false |
true 、false |
注意:
char
在 Java 中占两个字节。
可以看到,像 byte、short、int、long 能表示的最大正数其指数都减 1 了,这是为什么呢?
这是因为在二进制补码表示法中,最高位是用来表示符号的(0 表示正数,1 表示负数),其余位表示数值部分。所以,如果我们要表示最大的正数,我们需要把除了最高位之外的所有位都设为 1。
如果我们再加 1,就会导致溢出,变成一个负数。
对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。
另外,Java 的每种基本类型所占存储空间的大小不会像其他大多数语言那样随机器硬件架构的变化而变化。这种所占存储空间大小的不变性是 Java 程序比用其他大多数语言编写的程序更具可移植性的原因之一。
注意:
-
Java 里使用
long
类型的数据一定要在数值后面加上 L,否则,将作为整型解析。 -
char 和 String 类型的区别:char 使用单引号,String 使用双引号:
-
char a = 'h'
-
String a = "hello"
-
这八种基本类型都有对应的包装类分别为:Byte
、Short
、Integer
、Long
、Float
、Double
、Character
、Boolean
。
基本类型和包装类型的区别
-
用途:除了定义一些常量和局部变量之外,我们在其他地方比如方法参数、对象属性中很少会使用基本类型来定义变量。并且,包装类型可用于泛型,而基本类型不可以。
-
存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被 static 修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。
-
占用空间:相比于包装类型(对象类型),基本数据类型占用的空间往往非常小。
-
默认值:成员变量包装类型不赋值,默认就是null ,而基本类型有默认值且不是 null。
-
比较方式:对于基本数据类型来说,== 比较的是值。对于包装数据类型来说,== 比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用
equals()
方法。
为什么说是几乎所有对象实例都存在于堆中呢?
这是因为 HotSpot 虚拟机引入了 JIT 优化之后,会对对象进行逃逸分析,如果发现某一个对象并没有逃逸到方法外部,那么就可能通过标量替换来实现栈上分配,而避免堆上分配内存。
注意:基本数据类型存放在栈中是一个常见的误区!
基本数据类型的成员变量如果没有被 static 修饰的话,就存放在堆中。(不建议这么使用,应该要使用基本数据类型对应的包装类型)
class BasicTypeVar{
private int x;
}
包装类型的缓存机制
Java 基本数据类型的包装类型的大部分都用到了缓存机制来提升性能。
-
Byte
,Short
,Integer
,Long
这 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据, -
Character
创建了数值在 [0,127] 范围的缓存数据, -
Boolean
直接返回True
orFalse
。
Integer 缓存源码:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
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;
}
}
Character 缓存源码:
public static Character valueOf(char c) {
if (c <= 127) { // must cache
return CharacterCache.cache[(int)c];
}
return new Character(c);
}
private static class CharacterCache {
private CharacterCache(){}
static final Character cache[] = new Character[127 + 1];
static {
for (int i = 0; i < cache.length; i++)
cache[i] = new Character((char)i);
}
}
Boolean 缓存源码:
public static Boolean valueOf(boolean b) {
return (b ? TRUE : FALSE);
}
如果超出对应范围仍然会去创建新的对象,缓存的范围区间的大小只是在性能和资源之间的权衡。
两种浮点数类型的包装类 Float
,Double
并没有实现缓存机制。
例如,
Integer i1 = 33;
Integer i2 = 33;
System.out.println(i1 == i2); // 输出 true
Float i11 = 333f;
Float i22 = 333f;
System.out.println(i11 == i22); // 输出 false
Double i3 = 1.2;
Double i4 = 1.2;
System.out.println(i3 == i4); // 输出 false
Integer i1 = 40;
Integer i2 = new Integer(40);
System.out.println(i1==i2); // 输出 false
对于上述例子中的,最后一个比较:
-
Integer i1 = 40
这一行代码会发生装箱,也就是说这行代码等价于Integer i1=Integer.valueOf(40)
,因此,i1
直接使用的是缓存中的对象。 -
Integer i2 = new Integer(40)
会直接创建新的对象。
因此,答案是 false
。
自动装箱与拆箱
自动拆装箱:
-
装箱:将基本类型用它们对应的引用类型包装起来;
-
拆箱:将包装类型转换为基本数据类型;
例如:
Integer i = 10; //装箱
int n = i; //拆箱
上面这两行代码对应的字节码为:
L1
LINENUMBER 8 L1
ALOAD 0
BIPUSH 10
INVOKESTATIC java/lang/Integer.valueOf (I)Ljava/lang/Integer;
PUTFIELD AutoBoxTest.i : Ljava/lang/Integer;
L2
LINENUMBER 9 L2
ALOAD 0
ALOAD 0
GETFIELD AutoBoxTest.i : Ljava/lang/Integer;
INVOKEVIRTUAL java/lang/Integer.intValue ()I
PUTFIELD AutoBoxTest.n : I
RETURN
从字节码中,我们发现装箱其实就是调用了 包装类的 valueOf()
方法,拆箱其实就是调用了 xxxValue()
方法。
因此,
-
Integer i = 10
等价于Integer i = Integer.valueOf(10)
-
int n = i
等价于int n = i.intValue()
注意:如果频繁拆装箱的话,也会严重影响系统的性能。我们应该尽量避免不必要的拆装箱操作。
例如,如下例子,应该使用基本类型 long, 而不是 Long:
private static long sum() {
// 应该使用 long 而不是 Long
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}
浮点数运存在算精度丢失风险
浮点数运算精度丢失代码演示:
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(发生循环)
...
如何解决浮点数运算的精度丢失问题?
BigDecimal 可以实现对浮点数的运算,不会造成精度丢失。通常情况下,大部分需要浮点数精确运算结果的业务场景(比如涉及到钱的场景)都是通过 BigDecimal 来做的。
BigDecimal a = new BigDecimal("1.0");
BigDecimal b = new BigDecimal("0.9");
BigDecimal c = new BigDecimal("0.8");
BigDecimal x = a.subtract(b);
BigDecimal y = b.subtract(c);
System.out.println(x); /* 0.1 */
System.out.println(y); /* 0.1 */
System.out.println(Objects.equals(x, y)); /* true */
超过 long 整型的数据应该如何表示?
在 Java 中,如果需要表示超过 long 类型范围的数据,可以使用 BigInteger 类。BigInteger 是 Java 提供的一个用于处理任意精度整数的类,它可以表示非常大或非常小的整数。
BigInteger 内部使用 int[] 数组来存储任意大小的整形数据。
示例:
import java.math.BigInteger;
BigInteger a = new BigInteger("1234567890");
BigInteger b = new BigInteger("9876543210");
// 加法
BigInteger sum = a.add(b);
System.out.println(sum); // 输出:11111111100
// 减法
BigInteger difference = a.subtract(b);
System.out.println(difference); // 输出:-8641975320
// 乘法
BigInteger product = a.multiply(b);
System.out.println(product); // 输出:12193263111263526900
// 除法
BigInteger quotient = a.divide(b);
System.out.println(quotient); // 输出:0
// 求余
BigInteger remainder = a.remainder(b);
System.out.println(remainder); // 输出:1234567890
// 比较大小
int compareResult = a.compareTo(b);
System.out.println(compareResult); // 输出:-1(a < b)
变量
成员变量与局部变量
-
语法形式:从语法形式上看,成员变量是属于类的,而局部变量是在代码块或方法中定义的变量或是方法的参数;成员变量可以被 public,private,static 等修饰符所修饰,而局部变量不能被访问控制修饰符及 static 所修饰;但是,成员变量和局部变量都能被 final 所修饰。
-
存储方式:从变量在内存中的存储方式来看,如果成员变量是使用 static 修饰的,那么这个成员变量是属于类的,如果没有使用 static 修饰,这个成员变量是属于实例的。而对象存在于堆内存,局部变量则存在于栈内存。
-
生存时间:从变量在内存中的生存时间上看,成员变量是对象的一部分,它随着对象的创建而存在,而局部变量随着方法的调用而自动生成,随着方法的调用结束而消亡。
-
默认值:从变量是否有默认值来看,成员变量如果没有被赋初始值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。
静态变量
静态变量也就是被 static 关键字修饰的变量。它可以被类的所有实例共享,无论一个类创建了多少个对象,它们都共享同一份静态变量。也就是说,静态变量只会被分配一次内存,即使创建多个对象,这样可以节省内存。
静态变量是通过类名来访问的。
字符型常量和字符串常量
-
形式 : 字符常量是单引号引起的一个字符,字符串常量是双引号引起的 0 个或若干个字符。
-
含义 : 字符常量相当于一个整型值( ASCII 值),可以参加表达式运算; 字符串常量代表一个地址值(该字符串在内存中存放位置)。
-
占内存大小:字符常量只占 2 个字节; 字符串常量占若干个字节。
示例:
public class StringExample {
// 字符型常量
public static final char LETTER_A = 'A';
// 字符串常量
public static final String GREETING_MESSAGE = "Hello, world!";
public static void main(String[] args) {
System.out.println("字符型常量占用的字节数为:" + Character.BYTES); // 2
System.out.println("字符串常量占用的字节数为:" + GREETING_MESSAGE.getBytes().length); // 13
}
}
面向对象基础
深拷贝和浅拷贝的区别
关于深拷贝和浅拷贝区别,我这里先给结论:
-
浅拷贝:浅拷贝会在堆上创建一个新的对象(区别于引用拷贝的一点),不过,如果原对象内部的属性是引用类型的话,浅拷贝会直接复制内部对象的引用地址,也就是说拷贝对象和原对象共用同一个内部对象。
-
深拷贝:深拷贝会完全复制整个对象,包括这个对象所包含的内部对象。
浅拷贝
示例:
public class Address implements Cloneable{
private String name;
// 省略构造函数、Getter&Setter方法
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
System.out.println(person1.getAddress() == person1Copy.getAddress()); // true
从输出结果就可以看出, person1 的克隆对象和 person1 使用的仍然是同一个 Address 对象。
深拷贝
这里我们简单对 Person 类的 clone() 方法进行修改,连带着要把 Person 对象内部的 Address 对象一起复制。
public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
person.setAddress(person.getAddress().clone());
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
System.out.println(person1.getAddress() == person1Copy.getAddress()); // false
从输出结果就可以看出,显然 person1 的克隆对象和 person1 包含的 Address 对象已经是不同的了。
总结
那什么是引用拷贝呢?
简单来说,引用拷贝就是两个不同的引用指向同一个对象。
浅拷贝、深拷贝、引用拷贝的区别,如下所示:
Object
Object 类是一个特殊的类,是所有类的父类。它主要提供了以下 11 个方法:
/**
* native 方法,用于返回当前运行时对象的 Class 对象,使用了 final 关键字修饰,故不允许子类重写。
*/
public final native Class<?> getClass()
/**
* native 方法,用于返回对象的哈希码,主要使用在哈希表中,比如 JDK 中的HashMap。
*/
public native int hashCode()
/**
* 用于比较 2 个对象的内存地址是否相等,String 类对该方法进行了重写以用于比较字符串的值是否相等。
*/
public boolean equals(Object obj)
/**
* native 方法,用于创建并返回当前对象的一份拷贝。
*/
protected native Object clone() throws CloneNotSupportedException
/**
* 返回类的名字实例的哈希码的 16 进制的字符串。建议 Object 所有的子类都重写这个方法。
*/
public String toString()
/**
* native 方法,并且不能重写。唤醒一个在此对象监视器上等待的线程(监视器相当于就是锁的概念)。如果有多个线程在等待只会任意唤醒一个。
*/
public final native void notify()
/**
* native 方法,并且不能重写。跟 notify 一样,唯一的区别就是会唤醒在此对象监视器上等待的所有线程,而不是一个线程。
*/
public final native void notifyAll()
/**
* native方法,并且不能重写。暂停线程的执行。注意:sleep 方法没有释放锁,而 wait 方法释放了锁 ,timeout 是等待时间。
*/
public final native void wait(long timeout) throws InterruptedException
/**
* 多了 nanos 参数,这个参数表示额外时间(以纳秒为单位,范围是 0-999999)。 所以超时的时间还需要加上 nanos 纳秒。。
*/
public final void wait(long timeout, int nanos) throws InterruptedException
/**
* 跟之前的2个wait方法一样,只不过该方法一直等待,没有超时时间这个概念
*/
public final void wait() throws InterruptedException
/**
* 实例被垃圾回收器回收的时候触发的操作
*/
protected void finalize() throws Throwable { }
== 和 equals() 的区别
- 对于基本数据类型来说,
==
比较的是值。 - 对于引用数据类型来说,
==
比较的是对象的内存地址。
因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。
equals() 不能用于判断基本数据类型的变量,只能用来判断两个对象是否相等。equals()方法存在于Object类中,而Object类是所有类的直接或间接父类,因此所有的类都有equals()方法。
Object 类 equals()
方法:
public boolean equals(Object obj) {
return (this == obj);
}
示例:
String a = new String("ab"); // a 为一个引用
String b = new String("ab"); // b为另一个引用,对象的内容一样
String aa = "ab"; // 放在常量池中
String bb = "ab"; // 从常量池中查找
System.out.println(aa == bb); // true
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
System.out.println(42 == 42.0); // true
String 中的 equals
方法是被重写过的,因为 Object 的 equals
方法是比较的对象的内存地址,而 String 的 equals
方法比较的是对象的值。
当创建 String 类型的对象时,虚拟机会在常量池中查找有没有已经存在的值和要创建的值相同的对象,如果有就把它赋给当前引用。如果没有就在常量池中重新创建一个 String 对象。
Strin g类 equals()
方法:
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}
hashCode
hashCode() 的作用是获取哈希码,也称为散列码。它实际上是返回一个 int 整数。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode 作用是:确定该类的每一个对象在散列表中的位置,其它情况下(例如,创建类的单个对象,或者创建类的对象数组等等),该类的 hashCode() 没有作用。
上面的散列表指的是:Java集合中本质是散列表的类,如:HashMap
,Hashtable
,HashSet
。
也就是说:hashCode() 在散列表中才有用,在其它情况下没用。在散列表中 hashCode() 的作用是获取对象的散列码,进而确定该对象在散列表中的位置。
hashCode() 和 equals() 的关系
不会创建“类对应的散列表”
如果我们不会在 HashSet
、Hashtable
、HashMap
等散列表的数据结构中用到该类,例如,不会创建该类的 HashSet 集合。
在这种情况下,该类的 hashCode() 和 equals() 没有任何联系的!
示例:
import java.util.*;
import java.lang.Comparable;
public class NormalHashCodeTest{
public static void main(String[] args) {
Person p1 = new Person("eee", 100);
Person p2 = new Person("eee", 100);
Person p3 = new Person("aaa", 200);
System.out.printf("p1.equals(p2) : %s; p1(%d) p2(%d)\n", p1.equals(p2), p1.hashCode(), p2.hashCode());
System.out.printf("p1.equals(p3) : %s; p1(%d) p3(%d)\n", p1.equals(p3), p1.hashCode(), p3.hashCode());
}
private static class Person {
int age;
String name;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return name + " - " +age;
}
public boolean equals(Object obj){
if(obj == null){
return false;
}
// 如果是同一个对象返回true,反之返回false
if(this == obj){
return true;
}
// 判断是否类型相同
if(this.getClass() != obj.getClass()){
return false;
}
Person person = (Person)obj;
return name.equals(person.name) && age==person.age;
}
}
}
运行结果:
p1.equals(p2) : true; p1(1169863946) p2(1901116749)
p1.equals(p3) : false; p1(1169863946) p3(2131949076)
从结果也可以看出:p1 和 p2 相等的情况下,hashCode() 也不一定相等。
会创建“类对应的散列表”
如果我们会在 HashSet
、Hashtable
、HashMap
等散列表的数据结构中用到该类,例如,我们会创建该类的 HashSet 集合。
在这种情况下,该类的 hashCode() 和 equals() 是有关系的:
-
如果两个对象相等,那么它们的 hashCode() 值一定相同。
这里的相等是指,通过 equals() 比较两个对象时返回 true。
-
如果两个对象 hashCode() 相等,它们并不一定相等。
因为在散列表中,hashCode() 相等,即两个键值对的哈希值相等。然而哈希值相等,并不一定能得出键值对相等(可能出现哈希碰撞)。
因此,在这种情况下,如果要判断两个对象是否相等,除了要覆盖 equals() 之外,也要覆盖 hashCode() 函数;否则,equals() 无效。
例如,创建 Person 类的 HashSet 集合,必须同时覆盖 Person 类的 equals() 和 hashCode() 方法。如果单单只是覆盖 equals() 方法。我们会发现 equals() 方法没有达到我们想要的效果。
示例:
import java.util.*;
import java.lang.Comparable;
public class ConflictHashCodeTest1{
public static void main(String[] args) {
// 新建Person对象,
Person p1 = new Person("eee", 100);
Person p2 = new Person("eee", 100);
Person p3 = new Person("aaa", 200);
// 新建HashSet对象
HashSet set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
// 比较p1 和 p2, 并打印它们的hashCode()
System.out.printf("p1.equals(p2) : %s; p1(%d) p2(%d)\n", p1.equals(p2), p1.hashCode(), p2.hashCode());
// 打印set
System.out.printf("set:%s\n", set);
}
private static class Person {
int age;
String name;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return "("+name + ", " +age+")";
}
@Override
public boolean equals(Object obj){
if(obj == null){
return false;
}
// 如果是同一个对象返回true,反之返回false
if(this == obj){
return true;
}
// 判断是否类型相同
if(this.getClass() != obj.getClass()){
return false;
}
Person person = (Person)obj;
return name.equals(person.name) && age==person.age;
}
}
}
运行结果:
p1.equals(p2) : true; p1(1169863946) p2(1690552137)
set:[(eee, 100), (eee, 100), (aaa, 200)]
可以看出,虽然我们重写了 Person 的 equals(),但是,很奇怪的发现 HashSet 中仍然有重复元素:p1 和 p2。
为什么会出现这种情况呢?
这是因为虽然 p1 和 p2 的内容相等,但是它们的 hashCode() 不等,所以,HashSet 在添加 p1 和 p2 的时候,认为它们不相等。
下面,我们同时覆盖equals() 和 hashCode()方法。
示例:
import java.util.*;
import java.lang.Comparable;
public class ConflictHashCodeTest2{
public static void main(String[] args) {
// 新建Person对象,
Person p1 = new Person("eee", 100);
Person p2 = new Person("eee", 100);
Person p3 = new Person("aaa", 200);
Person p4 = new Person("EEE", 100);
// 新建HashSet对象
HashSet set = new HashSet();
set.add(p1);
set.add(p2);
set.add(p3);
// 比较p1 和 p2, 并打印它们的hashCode()
System.out.printf("p1.equals(p2) : %s; p1(%d) p2(%d)\n", p1.equals(p2), p1.hashCode(), p2.hashCode());
// 比较p1 和 p4, 并打印它们的hashCode()
System.out.printf("p1.equals(p4) : %s; p1(%d) p4(%d)\n", p1.equals(p4), p1.hashCode(), p4.hashCode());
// 打印set
System.out.printf("set:%s\n", set);
}
private static class Person {
int age;
String name;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String toString() {
return name + " - " +age;
}
@Override
public int hashCode(){
int nameHash = name.toUpperCase().hashCode();
return nameHash ^ age;
}
@Override
public boolean equals(Object obj){
if(obj == null){
return false;
}
// 如果是同一个对象返回true,反之返回false
if(this == obj){
return true;
}
// 判断是否类型相同
if(this.getClass() != obj.getClass()){
return false;
}
Person person = (Person)obj;
return name.equals(person.name) && age==person.age;
}
}
}
运行结果:
p1.equals(p2) : true; p1(68545) p2(68545)
p1.equals(p4) : false; p1(68545) p4(68545)
set:[aaa - 200, eee - 100]
可以看出重写 hashCode 方法后,HashSet 中没有重复元素了。
总结
两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。
如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。
总结:equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。两个对象有相同的 hashCode 值,他们也不一定是相等的(哈希碰撞)。
String
String、StringBuffer、StringBuilder 的区别
AbstractStringBuilder 是 StringBuilder 与 StringBuffer 的公共父类,定义了一些字符串的基本操作,如 expandCapacity、append、insert、indexOf 等公共方法。
可变性
String 是不可变的。
String 类中使用 private final
关键字修饰字符数组来保存字符串,并且没有暴露内部成员字段,因此,同时,将该字段设置了 final 防止被子类继承后破坏 String 的不可变性。
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
...
}
StringBuilder 与 StringBuffer 都继承自 AbstractStringBuilder 类,在 AbstractStringBuilder 中也是使用字符数组保存字符串,不过没有使用 final 和 private 关键字修饰,最关键的是这个 AbstractStringBuilder 类还提供了很多修改字符串的方法比如 append 方法。
abstract class AbstractStringBuilder implements Appendable, CharSequence {
char[] value;
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
//...
}
线程安全性
-
String 中的对象是不可变的,也就可以理解为常量,线程安全。
-
StringBuffer 对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。
-
StringBuilder 并没有对方法进行加同步锁,所以是非线程安全的。
性能
每次对 String 类型进行改变的时候,都会生成一个新的 String 对象,然后将指针指向新的 String 对象。
StringBuffer 每次都会对 StringBuffer 对象本身进行操作,而不是生成新的对象并改变对象引用。
相同情况下使用 StringBuilder 相比使用 StringBuffer 仅能获得 10% ~ 15% 左右的性能提升,但却要冒多线程不安全的风险。
对于三者使用的总结:
-
操作少量的数据: 适用 String
-
单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
-
多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer
字符串拼接用 “+” 还是 StringBuilder?
示例:
String str1 = "he";
String str2 = "llo";
String str3 = "world";
String str4 = str1 + str2 + str3;
如果字符串对象通过 “+” 的字符串拼接方式,实际上是通过 StringBuilder 调用 append() 方法实现的,拼接完成之后调用 toString() 得到一个 String 对象。
因此,如果在循环体内使用 “+” 进行字符串的拼接的话,存在比较明显的缺陷:编译器不会创建单个 StringBuilder 以复用,会导致创建过多的 StringBuilder 对象。StringBuilder 对象是在循环内部被创建的,这意味着每循环一次就会创建一个 StringBuilder 对象。
如果直接使用 StringBuilder 对象进行字符串拼接的话,就不会存在这个问题了。
示例:
String[] arr = {"he", "llo", "world"};
StringBuilder s = new StringBuilder();
for (String value : arr) {
s.append(value);
}
String 对象
如下示例代码,String s1 = new String("abc")
会创建 2 个字符串对象。
示例1:
String s1 = new String("abc");
如下示例代码,String s1 = new String("abc")
会创建 1 个字符串对象。
示例2:
// 字符串常量池中已存在字符串对象“abc”的引用
String s1 = "abc";
// 下面这段代码只会在堆中创建 1 个字符串对象“abc”
String s2 = new String("abc");
原因分析:
-
如果字符串常量池中不存在字符串对象 “abc” 的引用,那么它将首先在字符串常量池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。
-
如果字符串常量池中已存在字符串对象 “abc” 的引用,则只会在堆空间中创建 1 个字符串对象“abc”。
异常
异常机制是指当程序出现错误后,程序如何处理。具体来说,异常机制提供了程序退出的安全通道。当出现错误后,程序执行的流程发生改变,程序的控制权转移到异常处理器。
Java 异常类层次结构图概览:
Exception 和 Error 的区别
在 Java 中,所有的异常都有一个共同的祖先 java.lang.Throwable 类。
Throwable 类有两个重要的子类:
-
Exception:程序本身可以处理的异常。
Exception 可以分为两部分:
-
派生自 RuntimeException 的异常(非受查异常):由于程序错误导致的异常。即“如果出现 RuntimeException 异常,那么,就一定是你的问题”。
-
除了 RuntimeException 以外的异常(受查异常):程序本身没有问题,但是由于像 I/O 错误这类问题导致的异常,属于其他异常。
-
-
派生自 Error 的异常(非受查异常):Java 运行时系统的内部和资源耗尽错误,属于程序无法处理的错误。
例如,Java 虚拟机运行错误(Virtual MachineError)、虚拟机内存不够错误(OutOfMemoryError)、类定义错误(NoClassDefFoundError)等 。这些异常发生时,Java 虚拟机(JVM)一般会选择线程终止。
hecked Exception 和 Unchecked Exception 的区别
Checked Exception 即 受检查异常 ,Java 代码在编译过程中,如果受检查异常没有被 catch或者throws 关键字处理的话,就没办法通过编译。
例如,下面这段 IO 操作的代码:
示例:
public static void main(String[] args) {
try {
FileReader file = new FileReader("somefile.txt");
} catch (FileNotFoundException e) {
//Alternate logic
e.printStackTrace();
}
}
try-catch-finally
-
try :用于捕获异常。其后可接零个或多个 catch 块,如果没有 catch 块,则必须跟一个 finally 块。
-
catch 块:用于处理 try 捕获到的异常。
-
finally 块:无论是否捕获或处理异常,finally 块里的语句都会被执行。当在 try 块或 catch 块中遇到 return 语句时,finally 语句块将在方法返回之前被执行。
注意:不要在 finally 语句块中使用 return !
当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被忽略。因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。
示例:
public static void main(String[] args) {
System.out.println(f(2));
}
public static int f(int value) {
try {
return value * value;
} finally {
if (value == 2) {
return 0;
}
}
}
输出:
0
finally 中的代码一定会执行吗?
不一定的!在某些情况下,finally 中的代码不会被执行。
发生以下情况,finally 块中的代码就不会被执行:
-
finally 之前虚拟机被终止运行的话,finally 中的代码就不会被执行。
-
程序所在的线程死亡。
-
关闭 CPU。
示例:
try {
System.out.println("Try to do something");
throw new RuntimeException("RuntimeException");
} catch (Exception e) {
System.out.println("Catch Exception -> " + e.getMessage());
// 终止当前正在运行的Java虚拟机
System.exit(1);
} finally {
System.out.println("Finally");
}
输出:
Try to do something
Catch Exception -> RuntimeException
泛型
泛型一般有三种使用方式:泛型类、泛型接口、泛型方法。
泛型类
示例:
public class Generic<T>{
private T key;
public Generic(T key) {
this.key = key;
}
// 普通的成员方法,不是泛型方法
public T getKey(){
return key;
}
}
实例化泛型类:
Generic<Integer> genericInteger = new Generic<Integer>(123456);
泛型接口
示例:
public interface Generator<T> {
public T method();
}
实现泛型接口:
class GeneratorImpl<T> implements Generator<String> {
@Override
public String method() {
return "hello";
}
}
泛型方法
普通泛型方法
首先在 public 与返回值之间的
示例:
public <T> T genericMethod(Class<T> tClass)throws InstantiationException, IllegalAccessException{
T instance = tClass.newInstance();
return instance;
}
调用泛型方法:
Object obj = genericMethod(Class.forName("com.test.test"));
注意:只有声明了 <T>
的方法才是泛型方法,泛型类中的使用了泛型的成员方法并不是泛型方法。
通配符泛型方法
我们知道 Ingeter 是 Number 的一个子类,实际上 Generic
示例:
public class Generic<T> {
private T key;
public Generic(T key) {
this.key = key;
}
public T getKey(){
return key;
}
}
...
public void showKeyValue1(Generic<Number> obj) {
Sysytem.out.println("key value is " + obj.getKey());
}
...
Generic<Integer> gInteger = new Generic<Integer>(123);
Generic<Number> gNumber = new Generic<Number>(456);
showKeyValue(gNumber);
showKeyValue(gInteger); // 编译报错:Generic<java.lang.Integer> cannot be applied to Generic<java.lang.Number>
通过提示信息我们可以看到 Generic<Integer>
不能被看作为 Generic<Number>
的子类。由此可以看出:同一种泛型可以对应多个版本(因为参数类型是不确定的),不同版本的泛型类实例是不兼容的。
因此,为了解决上述问题,我们可以使用通配符 ?
代替具体的类型参数,即可以把 ?
看成所有类型的父类。当操作类型时,不需要使用类型的具体功能时,只使用 Object 类中的功能,那么可以用 ?
通配符来表未知类型。
public void showKeyValue1(Generic<?> obj) {
Sysytem.out.println("key value is " + obj.getKey());
}
这样,当类型不确定的时候,就可以使用通配符 ?
来代表未知类型。
静态方法使用泛型类型
如果静态方法要使用泛型的话,必须将静态方法也定义成泛型方法 。
public class StaticGenerator<T> {
public static <T> void show(T t){
...
}
}
如果定义为:public static void show(T t){ }
,此时编译器会提示错误信息:StaticGenerator cannot be refrenced from static context
。
反射
注解
注解只有被解析之后才会生效,常见的解析方法有两种:
-
编译期直接扫描:编译器在编译 Java 代码的时候扫描对应的注解并处理,比如某个方法使用
@Override
注解,编译器在编译的时候就会检测当前的方法是否重写了父类对应的方法。 -
运行期通过反射处理:像框架中自带的注解,比如 Spring 框架的
@Value
、@Component
,都是通过反射来进行处理的。
SPI
SPI 即 Service Provider Interface ,字面意思就是:“服务提供者的接口”,即专门提供给服务提供者或者扩展框架功能的开发者去使用的一个接口。
SPI 将服务接口和具体的服务实现分离开来,将服务调用方和服务实现者解耦,能够提升程序的扩展性、可维护性。修改或者替换服务实现并不需要修改调用方。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动、日志接口、以及 Dubbo 的扩展实现等等。
SPI 和 API 的区别
一般模块之间都是通过接口进行通讯,那我们在服务调用方和服务实现方(也称服务提供者)之间引入一个“接口”。
-
当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API ,这种接口和实现都是放在实现方的。
-
当接口存在于调用方这边时,就是 SPI ,由接口调用方确定接口规则,然后由不同的厂商去根据这个规则对这个接口进行实现,从而提供服务。
如下图所示:
SPI 的优缺点
通过 SPI 机制能够大大地提高接口设计的灵活性,但是 SPI 机制也存在一些缺点,比如:
-
需要遍历加载所有的实现类,不能做到按需加载,这样效率还是相对较低的。
-
当多个 ServiceLoader 同时 load 时,会有并发问题。
序列化和反序列化
简单来说:
-
序列化:将数据结构或对象转换成二进制字节流的过程。
-
反序列化:将在序列化过程中所生成的二进制字节流转换成数据结构或者对象的过程。
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
序列化协议对应于 TCP/IP 4 层模型的哪一层?
如上图所示,OSI 七层协议模型中,表示层做的事情主要就是对应用层的用户数据进行处理转换为二进制流,或者,将二进制流转换成应用层的用户数据。
因为,OSI 七层协议模型中的应用层、表示层和会话层对应的都是 TCP/IP 四层模型中的应用层,所以序列化协议属于 TCP/IP 协议应用层的一部分。
如果有些字段不想进行序列化怎么办?
对于不想进行序列化的变量,使用 transient
关键字修饰。transient
关键字的作用是:
-
阻止实例中那些用此关键字修饰的的变量序列化;
-
当对象被反序列化时,被 transient 修饰的变量值不会被持久化和恢复。
常见序列化协议
比较常用的序列化协议有 Hessian、Kryo、Protobuf、ProtoStuff,这些都是基于二进制的序列化协议。
JDK 自带的序列化方式一般不会用 ,因为序列化效率低、存在安全问题,并且不支持跨语言调用。而像 JSON 和 XML 这种属于文本类序列化方式,虽然可读性比较好,但是性能较差,一般不会选择。
I/O
IO 即 Input/Output,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。
数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的:
-
输入流的基类
-
java.io.InputStream
:字节输入流 -
java.io.Reader
:字符输入流
-
-
输出流的基类
-
java.io.OutputStream
:字节输出流 -
java.io.Writer
:字符输出流
-
参考: