一、String 类实战
1.1 String 类特性(不可变性与字符串常量池)
在 Java 中,String类被设计为不可变类,这意味着一旦一个String对象被创建,其内容就不能被改变。查看String类的源码,你会发现三个关键设计:
- final类:String类被声明为final,防止被继承。这意味着没有子类可以重写其方法从而破坏不可变行为。
- private final的字符数组:字符串的数据实际存储在一个private final char value[](或 JDK 9 后的byte[])中。关键字private确保了外部无法直接访问这个数组,final确保了该数组的引用不可更改(但请注意,final只能保证引用不变,并不能保证数组内容不变)。
- 无修改内容的公共方法:String类没有任何会修改内部字符数组内容的公共方法。所有像substring() 、replace()、trim()等方法,如果需要修改内容,都会在内部创建一个新的String对象并返回。
例如:
String s = "hello";
s = s + " world";
在这段代码中,首先创建了一个值为"hello"的String对象,当执行s = s + " world"时,并不是在原来的String对象上进行修改,而是创建了一个新的String对象,其值为"hello world",原来的"hello"对象仍然存在于内存中,只是不再有引用指向它,等待垃圾回收器回收。
String类的不可变性带来了许多好处,比如安全性、线程安全以及缓存哈希码提高性能等。因为字符串不可变,所以可以安全地在多个线程中共享,不用担心数据被意外修改;同时,由于其内容不会改变,所以可以在第一次计算哈希码后将其缓存起来,提高在哈希集合(如HashMap)中的操作性能。
而字符串常量池是Java中一个特殊的内存区域,用于存储字符串字面量。当创建一个字符串字面量时,JVM首先检查字符串常量池中是否已经有相同的字符串存在。如果存在,JVM将返回已存在字符串的引用;如果不存在,JVM会在字符串常量池中创建一个新的字符串,并返回其引用。例如:
String s1 = "java";
String s2 = "java";
System.out.println(s1 == s2);
输出结果为true,这是因为s1和s2指向的是字符串常量池中同一个"java"对象。而使用new关键字创建字符串时,会在堆内存中创建一个新的对象,例如:
String s3 = new String("java");
System.out.println(s1 == s3);
输出结果为false,因为s3是在堆内存中新建的对象,和字符串常量池中的对象不是同一个引用。字符串常量池的存在节省了内存空间,相同内容的字符串只在内存中存储一份,同时也提升了性能,通过重用对象,可以加快字符串的比较和操作速度。
1.2 String 类常用方法(equals、length、substring 等)
String类提供了丰富的方法来操作字符串,以下是一些常用方法的介绍:
- equals(Object anObject):用于比较两个字符串的内容是否相等,区分大小写。例如:
String s1 = "Hello";
String s2 = "hello";
System.out.println(s1.equals(s2));
输出false。
- length():返回字符串的长度。例如:
String s = "Java is great";
System.out.println(s.length());
输出13。
- substring(int beginIndex):从指定索引位置开始截取字符串,包含开始索引位置的字符,一直截取到字符串末尾。例如:
String s = "Hello, World!";
System.out.println(s.substring(7));
输出World!。
- substring(int beginIndex, int endIndex):截取从beginIndex(包括)到endIndex(不包括)之间的字符串。例如:
String s = "Hello, World!";
System.out.println(s.substring(0, 5));
输出Hello。
1.3 字符串拼接与格式化(+、concat、String.format)
在 Java 中,有多种方式可以进行字符串的拼接与格式化:
- 使用+运算符:这是最常见的字符串拼接方式,简单直观。例如:
String s1 = "Hello";
String s2 = "World";
String result = s1 + ", " + s2 + "!";
System.out.println(result);
输出Hello, World!。在编译时,+运算符拼接字符串会被转换为StringBuilder的append方法调用,如果在循环中频繁使用+进行字符串拼接,会创建大量的StringBuilder对象,影响性能。
- concat(String str)方法:将指定的字符串连接到当前字符串的末尾。例如:
String s1 = "Hello";
String s2 = "World";
String result = s1.concat(", ").concat(s2).concat("!");
System.out.println(result);
输出同样是Hello, World!。concat方法每次调用都会创建一个新的String对象,所以在拼接多个字符串时,性能也不如StringBuilder。
- String.format(String format, Object… args)方法:用于格式化字符串,它根据指定的格式字符串和参数生成一个格式化的字符串。例如:
String name = "Tom";
int age = 20;
String result = String.format("Name: %s, Age: %d", name, age);
System.out.println(result);
输出Name: Tom, Age: 20。这种方式适用于需要对字符串进行复杂格式化的场景,比如按照指定格式输出日期、数字等。
1.4 String、StringBuilder、StringBuffer 区别与选择
String、StringBuilder和StringBuffer是 Java 中处理字符串的常用类,它们之间存在一些重要的区别:
- 可变性:String类是不可变的,任何对String的修改操作都会创建一个新的String对象;StringBuilder和StringBuffer是可变的,它们提供了一系列方法来直接修改字符串内容,而不需要创建新的对象。
- 线程安全性:String是线程安全的,因为其不可变性,多个线程可以安全地共享同一个String对象;StringBuffer的方法是同步的,所以它是线程安全的,适合在多线程环境中使用;StringBuilder的方法没有同步,所以它是非线程安全的,在单线程环境下使用性能更高。
- 性能:由于String的不可变性,频繁修改String会产生大量临时对象,性能较差;StringBuilder的性能优于StringBuffer,因为它不需要处理线程同步开销,在单线程环境下进行字符串拼接等操作时效率更高。
在选择使用哪个类时,可以根据具体的场景来决定:
- 如果字符串内容不会发生变化,或者只需要进行少量的拼接操作,使用String类即可,因为它简单易用,并且在常量字符串的情况下可以利用字符串常量池优化内存。
- 如果在单线程环境下需要频繁地对字符串进行修改,比如在循环中进行字符串拼接,使用StringBuilder类,它的性能更高。例如:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
String result = sb.toString();
如果在多线程环境下需要频繁地对字符串进行修改,使用StringBuffer类,以确保线程安全。例如:
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
String result = sb.toString();
二、Object 类实战
2.1 Object 类常用方法(equals、hashCode、toString)
Object类是 Java 中所有类的基类,它提供了一些通用的方法,这些方法对于所有 Java 对象都适用。其中,equals、hashCode和toString是Object类中非常常用的方法。
equals方法用于判断两个对象是否相等,在Object类中的默认实现是比较两个对象的内存地址,即只有当两个对象是同一个对象时,equals方法才返回true。例如:
Object obj1 = new Object();
Object obj2 = new Object();
System.out.println(obj1.equals(obj2));
输出结果为false,因为obj1和obj2是不同的对象,内存地址不同。然而,在实际应用中,我们通常需要比较对象的内容是否相等,而不是内存地址,这就需要重写equals方法。
hashCode方法返回对象的哈希码值,这个哈希码值在哈希表(如HashMap、HashSet)等数据结构中用于确定对象的存储位置。在Object类中,hashCode方法的默认实现是根据对象的内存地址生成一个唯一的哈希码。例如:
Object obj1 = new Object();
Object obj2 = new Object();
System.out.println(obj1.hashCode());
System.out.println(obj2.hashCode());
通常情况下,obj1和obj2的哈希码值是不同的。哈希码的作用主要是为了提高在哈希表中查找和比较对象的效率。当向哈希表中添加对象时,先计算对象的哈希码,根据哈希码快速定位到可能存储该对象的位置,然后再通过equals方法进行精确比较,以确定是否为同一个对象。
toString方法返回对象的字符串表示形式,在Object类中的默认实现返回的是对象的类名加上@符号,再加上对象的哈希码的十六进制表示。例如:
Object obj = new Object();
System.out.println(obj.toString());
输出结果类似于java.lang.Object@15db9742。在实际开发中,默认的toString方法返回的信息往往不够直观,为了更好地展示对象的属性和状态,我们通常需要重写toString方法。
2.2 equals 方法重写(自定义类对象相等判断)
在 Java 中,当我们需要判断自定义类的两个对象是否相等时,不能直接使用Object类中默认的equals方法,因为它比较的是对象的内存地址,而不是对象的内容。例如,有一个自定义类Person:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
如果使用默认的equals方法来比较两个Person对象:
Person p1 = new Person("Alice", 20);
Person p2 = new Person("Alice", 20);
System.out.println(p1.equals(p2));
输出结果为false,尽管p1和p2的内容相同,但它们是不同的对象,内存地址不同。为了实现基于对象内容的相等判断,我们需要重写equals方法。重写equals方法时,通常需要遵循以下几个原则:
- 自反性:对于任何非空引用值x,x.equals(x)必须返回true。
- 对称性:对于任何非空引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。
- 传递性:对于任何非空引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)返回true,那么x.equals(z)也必须返回true。
- 一致性:对于任何非空引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就应该一致地返回true或者一致地返回false。
- 非空性:对于任何非空引用值x,x.equals(null)应返回false。
下面是重写Person类的equals方法的示例:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && name.equals(person.name);
}
}
在这个重写的equals方法中,首先判断两个对象是否是同一个对象(通过this == o),如果是则直接返回true;然后判断传入的对象是否为null或者类型是否与当前对象不同,如果是则返回false;最后比较两个对象的name和age属性是否相等,如果都相等则返回true,否则返回false。这样,当我们再次比较两个Person对象时:
Person p1 = new Person("Alice", 20);
Person p2 = new Person("Alice", 20);
System.out.println(p1.equals(p2));
输出结果就会为true,因为它们的内容相同。
2.3 hashCode 方法与 equals 方法的一致性原则
hashCode方法和equals方法之间存在着紧密的联系,它们需要保持一致性原则。在 Java 中,规定如果两个对象通过equals方法比较是相等的,那么它们的hashCode值必须相同。这是因为在使用哈希表(如HashMap、HashSet)等数据结构时,首先会根据对象的hashCode值来确定对象在哈希表中的存储位置,如果两个相等的对象hashCode值不同,就会导致它们被存储在不同的位置,从而破坏了哈希表的正常功能。例如,在HashMap中,如果我们向其中添加两个通过equals方法判断相等但hashCode值不同的对象,就会出现问题:
class Key {
private String value;
public Key(String value) {
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Key key = (Key) o;
return value.equals(key.value);
}
// 没有重写hashCode方法,使用Object类的默认实现
}
HashMap<Key, Integer> map = new HashMap<>();
Key key1 = new Key("test");
Key key2 = new Key("test");
map.put(key1, 1);
System.out.println(map.get(key2));
在这个例子中,key1和key2通过equals方法比较是相等的,但由于没有重写hashCode方法,它们的hashCode值不同(Object类默认的hashCode方法根据内存地址生成哈希码),所以在map中key2无法找到对应的value,输出结果为null。为了保证hashCode方法和equals方法的一致性,当重写equals方法时,也必须重写hashCode方法。重写hashCode方法时,通常需要根据对象的属性来生成哈希码,确保相等的对象生成相同的哈希码。例如,继续上面的Key类,重写hashCode方法:
class Key {
private String value;
public Key(String value) {
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Key key = (Key) o;
return value.equals(key.value);
}
@Override
public int hashCode() {
return value.hashCode();
}
}
这样,当再次执行上述代码时,map.get(key2)就能正确地返回1。
2.4 toString 方法重写(对象信息友好展示)
toString方法用于返回对象的字符串表示形式,默认的toString方法返回的信息往往不够直观,不能满足我们在实际开发中的需求。例如,对于前面定义的Person类,如果直接调用toString方法:
Person p = new Person("Bob", 25);
System.out.println(p.toString());
输出结果类似于Person@15db9742,这样的输出很难让我们直观地了解Person对象的属性值。为了使toString方法返回更有意义的对象信息,我们需要重写toString方法。重写toString方法时,通常将对象的属性值拼接成一个字符串返回。例如,重写Person类的toString方法:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return age == person.age && name.equals(person.name);
}
@Override
public int hashCode() {
return name.hashCode() * 31 + age;
}
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
重写后的toString方法将Person对象的name和age属性以一种易读的格式拼接成字符串返回。当再次调用toString方法时:
Person p = new Person("Bob", 25);
System.out.println(p.toString());
输出结果为Person{name=‘Bob’, age=25},这样我们就能清晰地看到Person对象的属性值,方便调试和日志记录等操作。
三、包装类实战
3.1 基本数据类型与包装类对应关系
在 Java 中,基本数据类型是语言内置的简单数据类型,它们不是对象,不具备对象的属性和方法。而包装类则是为了将基本数据类型 “包装” 成对象,使其能够参与面向对象的编程,并且提供了一些实用的方法和常量。基本数据类型与包装类的对应关系如下:
基本数据类型 | 包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
例如,int是基本数据类型,而Integer是它对应的包装类。包装类的主要作用包括:
- 用于泛型和集合框架:Java 中的泛型和集合框架(如ArrayList、HashMap等)只能存储对象类型,不能直接存储基本数据类型。通过包装类,我们可以将基本数据类型转换为对象,从而能够在这些数据结构中使用。例如:
ArrayList<Integer> list = new ArrayList<>();
list.add(10);
- 提供更多的方法和常量:包装类提供了许多实用的静态方法,如将字符串转换为数值的parseInt、parseDouble等方法,以及表示最大值、最小值的常量(如Integer.MAX_VALUE、Double.MIN_VALUE)。例如:
int num = Integer.parseInt("123");
- 表示空值:基本数据类型必须有值,而包装类对象可以为null,可以用来表示缺失或未定义的数据。例如:
Integer num = null;
3.2 自动装箱与自动拆箱(原理与常见问题)
自动装箱和自动拆箱是 Java 5.0 引入的重要特性,它极大地简化了基本数据类型和包装类之间的转换操作。
自动装箱是指 Java 自动将基本数据类型转换为对应的包装类对象。例如:
Integer num = 10;
这里,int类型的10被自动装箱成了Integer类型的对象。实际上,编译器会在背后调用Integer.valueOf(10)方法来完成装箱操作。
自动拆箱则是自动装箱的逆过程,即自动将包装类对象转换为基本数据类型。例如:
Integer num = 10;
int i = num;
这里,Integer类型的num被自动拆箱成了int类型的i,编译器会调用num.intValue()方法来完成拆箱操作。
在使用自动装箱和自动拆箱时,需要注意以下常见问题:
- 性能问题:虽然自动装箱和拆箱简化了编程,但它们也可能引入性能问题。每次装箱和拆箱操作都会创建和销毁对象,这会增加垃圾回收的负担,并可能导致性能下降。特别是在进行大量数值运算或频繁访问集合时,应尽量避免不必要的装箱和拆箱操作。例如,在一个循环中进行频繁的装箱和拆箱:
Long sum = 0L;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
在这个例子中,sum是Long类型,每次sum += i操作都会进行自动装箱和拆箱,这会影响性能。可以将sum改为long类型来避免不必要的装箱和拆箱。
- 缓存机制与==运算符:Java 对Integer、Short 、Byte 、Character、Boolean等包装类提供了缓存机制,以节省内存和提高效率。例如,对于Integer,当值在 - 128 到 127 之间时,会直接从缓存中返回对象,而不是创建新对象。这意味着在这个范围内的Integer对象比较时,使用==和equals()会得到相同的结果。但超出这个范围,或者对于其他包装类型,==运算符将比较引用而非值,这可能导致意外的结果。例如:
Integer a = 127;
Integer b = 127;
System.out.println(a == b);
Integer c = 128;
Integer d = 128;
System.out.println(c == d);
System.out.println(c.equals(d));
输出结果为:
true
false
true
因为127在缓存范围内,a和b指向同一个对象,所以a == b为true;而128超出了缓存范围,c和d是不同的对象,所以c == d为false,但它们的值相等,所以c.equals(d)为true。
3.3 包装类常用方法(valueOf、parseInt、toString)
包装类提供了许多常用方法,以下是对valueOf、parseInt、toString等方法的详细讲解:
- valueOf方法:用于将基本数据类型或字符串转换为对应的包装类对象。它有多种重载形式,例如Integer.valueOf(int i)将int类型转换为Integer对象,Integer.valueOf(String s)将字符串转换为Integer对象。示例代码如下:
Integer num1 = Integer.valueOf(10);
Integer num2 = Integer.valueOf("123");
- parseInt方法:这是Integer类的静态方法,用于将字符串解析为int类型。例如Integer.parseInt(String s),其中String类型的参数必须是一个合法的整数表示形式,否则会抛出NumberFormatException异常。示例代码如下:
int num = Integer.parseInt("123");
- toString方法:用于将包装类对象转换为字符串形式。在包装类中,toString方法被重写,返回包装类对象所表示的值的字符串形式。例如Integer.toString(int i)返回int类型参数的字符串表示,Integer.toString()返回当前Integer对象所表示的整数值的字符串形式。示例代码如下:
String s1 = Integer.toString(123);
Integer num = 123;
String s2 = num.toString();
3.4 包装类实战案例(数据类型转换与比较)
下面通过具体的实战案例来展示包装类在数据类型转换和比较中的应用:
- 数据类型转换案例:将字符串类型的数字转换为int类型,并进行计算。
String str = "10";
// 将字符串转换为Integer包装类对象
Integer num = Integer.valueOf(str);
// 自动拆箱,将Integer对象转换为int类型,进行计算
int result = num + 5;
System.out.println("计算结果: " + result);
在这个案例中,首先使用Integer.valueOf方法将字符串"10"转换为Integer对象,然后通过自动拆箱将Integer对象转换为int类型,与5进行加法运算。
- 数据比较案例:比较两个Integer对象的值是否相等。
Integer num1 = 10;
Integer num2 = 10;
// 使用equals方法比较两个Integer对象的值
if (num1.equals(num2)) {
System.out.println("num1和num2的值相等");
} else {
System.out.println("num1和num2的值不相等");
}
在这个案例中,使用equals方法来比较两个Integer对象的值,因为==运算符在比较包装类对象时比较的是对象的引用,而不是值,所以在这里使用equals方法更合适,以确保比较的是对象所表示的值。通过这些实战案例,可以更好地理解包装类在实际编程中的应用和重要性。