本文假设读者已有其他面向对象高级语言的基础(如C++基础),在此基础上介绍 Java 的基础知识。第一期介绍了 Java 中的变量、类型、函数、包JAVA入门学习笔记#01(基础概念、基本语法和关键字)
本期主要介绍 Java 中的包装类、引用类型、数组与切片。Java 中循环判断、类与继承、类方法、重写与重载等概念与 C++ 中基本一致,在此不做介绍。
目录
下期讲反射和注解等。
Java 包装类
上篇提到, Java 中的每种基本类型都有对应的包装类,包装类是对象,其成员函数包括很多实用方法,Java 中八种基本类和包装类对应如下:
Integer
对应int
Double
对应double
Boolean
对应boolean
Character
对应char
Byte
对应byte
Short
对应short
Float
对应float
Long
对应long
装箱(Boxing)
装箱是指将基本数据类型自动转换为对应的引用数据类型(即包装类)。例如,将一个 int
类型的值转换为 Integer
对象。这是在Java 5 后引入自动装箱之后的一个特性,即 JDK5 之前需要手动创建包装类的实例。
int i = 10;
Integer integer = i; // 自动装箱
拆箱(Unboxing)
拆箱是将包装类的对象转换回基本数据类型。例如,将一个 Integer
对象转换为 int
类型的值。
Integer integer = 20; // 自动装箱
int i = integer; // 自动拆箱
说明
- 拆装箱使用场景:需将基本数据类型作为参数传递给需要对象的方法,或者存储在集合(如
List
和Set
)中时。 - 拆装箱涉及对象的创建和销毁,频繁拆装箱会带来过大的性能开销
Java 基本类型和引用类型
在Java中,除了八种基本数据类型之外,所有其他类型都被认为是引用类型。基本数据类型直接存储值,而引用类型存储的是对象的引用,即对象在内存中的地址。
基本数据类型
Java中有八种基本数据类型,分为两种:四种整数类型(byte
, short
, int
, long
)、两种浮点类型(float
, double
)、一个字符类型(char
)和一个布尔类型(boolean
)。这些类型直接在栈内存中分配空间存储具体的数值,当变量值改变时,实际的值会直接更新。
引用类型
除了基本数据类型,Java中的所有其他类型都属于引用类型,包括但不限于:
- 类(Class):这是Java中最常见的引用类型,如你自己定义的类、内置的
String
类等。 - 数组(Array):数组也是引用类型,即使它们包含基本数据类型,数组变量仍然只是指向数组首地址的引用。
- 接口(Interface):实现接口的类的实例是引用类型。
- 枚举(Enum):枚举类型也属于引用类型。
- 泛型(Generics):使用泛型的类或接口实例化后也是引用类型。
- 异常(Exception):异常类的实例也是引用类型。
- 集合框架(Collections):如
List
,Set
,Map
等集合类的实例。 - 线程(Thread):
Thread
类的实例。
引用类型在堆内存中分配空间,而变量(引用)则在栈内存中存储,指向堆中的对象。这意味着,当你创建一个引用类型的变量并初始化它时,你实际上是在栈内存中创建了一个指向堆中对象的引用。当你传递引用类型的变量时,你传递的是这个引用的拷贝,而不是对象本身。例如,考虑下面的代码:
class MyClass {
int value;
}
public class Main {
public static void main(String[] args) {
MyClass obj1 = new MyClass(); // 创建一个可以指向MyClass类型对象的引用
obj1.value = 5; // 对引用内容(指针指向的int)赋值
MyClass obj2 = obj1; // 此处传递的是引用
obj2.value = 10; // 修改了引用内容(指针指向的int)
System.out.println(obj1.value); // 输出: 10
}
}
在这个例子中,obj1
和obj2
都指向堆中同一个MyClass
对象。当我们将obj1
赋值给obj2
时,实际上是在栈中创建了一个新的引用obj2
,但它指向的是obj1
所指向的同一个对象。因此,改变obj2.value
也会影响到obj1.value
的值。
Java 引用
Java 中除了基本类型都是引用类型,而且 Java 中只有值传递(不像引用传递的功能。C++中有引用传递),但是 Java 中的引用类型(依然还是还是值传递,不过传递的是指向对象的地址值)可实现类似 C++ 中引用传递的功能。考虑如下引用类型(一个类):
public class Shuzhi {
public void test(){
int shuzhi=1;
}
}
以上代码完成的操作是:
- 创建了该基本数据类型(示例中为int)的引用,引用名为该基本数据类型名(示例中为shuzhi)。
- 在栈区创建了该基本数据类型(示例中为int)的变量内容(示例中为1)。
- 变量内容在编译器处理下实际为该内容的地址(示例中即1这个代码实际上返回1的地址),将内容的地址赋给引用,完成定义。
- 该数值类变量定义后被再次使用时都被会被自动解引用(即根据引用内容自动取内容),因此之后可以继续完成赋值等操作。如 shuzhi=2; 该代码完成的操作为(*shuzhi)=2
byte,char,long,float,double等数据类型引用的创建同int。
在Java中,“引用”和C/C++中的“指针”在概念上有相似之处,因为它们都代表了对对象的间接访问方式。但Java的引用在设计上更加安全,更加面向对象,同时也更加受限,以防止程序员直接控制内存。
Java 引用和 C++ 指针区别
-
安全性:Java的引用不允许进行低级的内存操作,例如你不能像C/C++那样直接操纵内存地址,或者做指针运算(如加减操作)。Java的引用只能用来访问对象的方法和字段。
-
垃圾回收:Java的引用类型受到垃圾回收器的管理。当一个对象不再被任何引用所指向时,它会被垃圾回收器自动回收,释放其占用的内存。在C/C++中,你需要显式地管理内存的分配和释放。
-
类型检查:Java的引用是强类型的,必须与对象的实际类型匹配。这避免了类型不匹配导致的运行时错误。
-
空引用:Java中的引用可以是
null
,表示它们不指向任何对象。这在编程中经常用于初始化阶段或表示对象尚未被创建。 -
引用类型:Java有多种引用类型,如强引用、软引用、弱引用和虚引用,它们在垃圾回收过程中的行为各不相同。
Java 数组
数组的声明方式
// 常用数组
int[] arr1;
char[] arr2;
String[] arr3;
// 创建方式1:静态创建
int[] numbers = {1, 2, 3, 4, 5}; // 一维数组
int[][] matrix = new int[3][4]; // 3行4列的二维数组
// 创建方式2:静动态创建
int[] numbers = new int[5];
数组的访问方式同 C++,可直接使用索引访问,如 numbers[0] 可获取第一个元素的值
Arrays 类提供很多内置方法来操作数组,如排序和填充等
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ArraysExamples {
/**
* 此示例展示了使用 java.util.Arrays 类的各种方法,
* 以及如何将数组转换为 List 和使用 Stream API 进行操作。
*/
public static void main(String[] args) {
int[] arr = {5, 2, 9, 1}; // 创建一个数组
Arrays.sort(arr); // 对数组进行排序
Arrays.fill(arr, 0); // 用0值填充整个数组
int[] copy = Arrays.copyOf(arr, arr.length); // 复制数组
copy = Arrays.copyOfRange(arr, 0, 3); // 复制数组的一部分
// 比较两个数组是否相等
boolean isEqual = Arrays.equals(new int[]{7, 7, 7}, copy);
String str = Arrays.toString(new int[]{1, 2, 3}); // 将数组转换为字符串格式
int index = Arrays.binarySearch(new int[]{1, 2, 3}, 2); // 在已排序的数组中搜索一个元素
// 将数组转换为 List(注意:返回的 List 是数组的视图,而不是数组的实例,)
List<Integer> list = Arrays.asList(1, 2, 3);
// 使用 Stream API 来处理数组元素
long total = Arrays.stream(new int[]{1, 2, 3}).sum();
// 使用 Stream 过滤数组元素
List<Integer> filteredList = Arrays.stream(new int[]{1, 2, 3, 4, 5})
.filter(n -> n > 3)
.boxed()
.collect(Collectors.toList());
// 使用 Lambda 表达式设置数组中所有元素的值
int[] setAllArray = new int[5];
Arrays.setAll(setAllArray, i -> i * i);
// System.out.println("使用 Lambda 设置值后的数组: " + Arrays.toString(setAllArray));
}
}
Java 集合
Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。对于Collection
接口,下面又有三个主要的子接口:List
、Set
、 Queue
。
Java 集合框架如下图所示:
Java集合框架主要包括两大类:Collection
和Map
。下面将详细介绍每种主要的集合类型,它们对应的数据结构以及适用的场景。
Collection 接口及其实现类
Collection
接口是所有集合类的根接口,它有三个主要的子接口:List,
Set 和 Queue。
-
List
ArrayList
:基于动态数组实现,适用于需要随机访问元素的场景,提供快速的O(1)时间复杂度的元素访问。LinkedList
:基于双向链表实现,适用于频繁插入和删除元素的场景,插入和删除操作的时间复杂度为O(1)。Vector
:类似于ArrayList
,基于动态数组,但提供线程安全机制,适用于多线程环境。Stack
:继承自Vector
,实现了一个后进先出(LIFO)的栈,适用于需要栈数据结构的场景,如算法中的递归替代、表达式求值等。
-
Set
HashSet
:基于哈希表实现,适用于需要快速查找、插入和删除元素的场景,平均时间复杂度为O(1)。TreeSet
:基于红黑树实现,适用于需要自动排序的场景,保证元素按自然顺序或自定义比较器排序,时间复杂度为O(log n)。LinkedHashSet
:结合了HashSet
和LinkedList
的特点,保持了元素的插入顺序,适用于需要保留插入顺序的场景。
-
Queue
ArrayDeque
:基于数组的循环双端队列,适用于需要两端都可以高效插入和删除元素的场景,如任务调度队列、消息队列等。
-
Deque(Double Ended Queue)
ArrayDeque
和LinkedList
:适用于需要在队列的两端进行插入和删除操作的场景。
Map 接口及其实现类
Map
接口用于存储键值对,其中键是唯一的。
- 数据结构:
Map
的主要实现包括HashMap
(基于哈希表)、TreeMap
(基于红黑树)和LinkedHashMap
(基于哈希表+链表)。 - 使用场景:
HashMap
:适用于需要快速查找、插入和删除键值对的场景,平均时间复杂度为O(1)。TreeMap
:适用于需要自动按键排序的场景,保证键按自然顺序或自定义比较器排序,时间复杂度为O(log n)。LinkedHashMap
:保持了键的插入顺序,适用于需要保留插入顺序的场景。Hashtable
:基于哈希表,与HashMap
类似,但Hashtable
是线程同步的,适用于多线程环境,需要线程安全的键值对存储场景。SortedMap
(接口):TreeMap
是其主要实现,适用于需要保持键值对有序的场景,如排序后的键值对集合。
Java 泛型
Java 泛型是 Java SE 5 引入的一项重要特性,它允许在编译时检查类型安全,并且所有的强制转换都是自动和隐式的,增加了代码的重用性和安全性。泛型使你能够编写类型安全的通用类或方法,而无需在运行时进行类型检查或转换,Java 的泛型有点像 C++ 的STL 和容器。
1. 泛型的定义
泛型本质上是一种参数化类型,也就是说,当我们定义类、接口或方法时,可以将类型作为一个参数。这样做的好处是,你可以编写一个通用的类或方法,它可以针对多种具体类型工作,而无需为每种类型都定义一个单独的类或方法。
2. 泛型的常见语法
泛型的使用通常涉及到类型参数、通配符和类型边界等概念。
-
类型参数: 在定义类、接口或方法时,你可以声明一个或多个类型参数。类型参数通常用大写字母表示,如
T
,E
,K
,V
等。例如:public class GenericClass<T> { private T value; public T getValue() { return value; } public void setValue(T value) { this.value = value; } }
-
通配符: 通配符(
?
)用于表示未知的类型,它可以接受任何类型的参数的方法,但同时也限制了对参数的操作。如:public void printList(List<?> list) { for (Object item : list) { System.out.println(item); } }
-
类型边界: 类型参数可以有边界,即指定类型参数必须是某一个类的子类或实现某一个接口。例如:
public class GenericClass<T extends Number> { // ... }
-
类型参数的上限和下限:
- 上限:
extends
关键字用于指定类型参数的上限。 - 下限:
super
关键字用于指定类型参数的下限。例如,T super Number
表示T
可以是Number
或其任何超类型。
- 上限:
下面是一个使用泛型的简单示例,展示如何定义一个泛型类和使用它。
// 定义一个泛型类
public class Box<T> {
private T item;
public Box(T item) {
this.item = item;
}
public T getItem() {
return item;
}
public void setItem(T item) {
this.item = item;
}
}
public class Main {
public static void main(String[] args) {
// 创建一个可以存储String类型的Box
// 使用字符串 Hello World 来对 Box 类进行构造
Box<String> stringBox = new Box<>("Hello, World!");
System.out.println(stringBox.getItem()); // 输出: Hello, World!
// 创建一个可以存储Integer类型的Box
// 使用整数 42 来对 Box 类进行构造
Box<Integer> intBox = new Box<>(42);
System.out.println(intBox.getItem()); // 输出: 42
}
}
在这个示例中,Box
类是一个泛型类,它可以用于存储任何类型的对象。在创建Box
的实例时,我们指定了具体的类型参数,如String
和Integer
,这样就可以创建特定类型的Box
实例。
注意,在表达式 Box<Integer> intBox = new Box<>(42); 中,<>
内的尖括号通常用于指定泛型参数的类型。然而,在Java 7及更高版本中,如果你在声明变量时已经指定了类型参数,那么在实例化集合时可以利用类型推断,此时<>
内的类型参数可以省略,这就是所谓的“钻石操作符”。
3. Collection 的泛型代码示例
例如,如果你有以下的代码:
List<String> names = new ArrayList<String>();
在Java 7之前,你需要显式地在ArrayList
后面跟上<String>
来指定类型参数。但在Java 7之后,由于编译器可以从左边的List<String>
推断出你需要的是一个存储String
类型的ArrayList
,因此你可以省略<String>
部分,代码可以简化为:
List<String> names = new ArrayList<>();
这里的<>
(没有指定类型参数)被称为“钻石操作符”,表示编译器应该使用类型推断来确定正确的类型参数。
4. 泛型等式左边的集合应该是右边的集合或其父类
仔细看上述代码,为什么等式左边是“List<String> names = new ArrayList<>();”,为什么不是“ArrayList<String> names = new ArrayList<>();”呢?
在Java中,你可以选择使用接口类型(如List<String>
)或者具体的实现类类型(如ArrayList<String>
)来声明变量。使用接口类型来声明变量(如List<String> names = new ArrayList<>();
)有以下几个好处:
-
抽象与实现解耦:当你使用接口类型声明变量时,你将变量的类型与其实现的具体类解耦。这意味着你可以随时改变底层使用的具体实现类(例如,从
ArrayList
改为LinkedList
),而无需修改所有使用该变量的代码。这提高了代码的灵活性和可维护性。 -
更好的兼容性和扩展性:由于变量是基于接口声明的,你可以在不影响现有代码的情况下,添加新的实现类。此外,如果你的代码依赖于接口而非具体实现,那么当接口有新的实现时,你的代码可以更容易地利用这些新实现。
-
编码风格和最佳实践:许多开发者和团队遵循一种编码规范,即使用接口类型来声明变量,这被认为是一种良好的编程习惯。它强调了代码应该依赖于抽象,而不是具体实现。
然而,使用具体实现类(如ArrayList<String> names = new ArrayList<>();
)来声明变量也是可行的,并且在某些情况下可能更直观,尤其是当具体实现的特性对代码逻辑至关重要时。
那如果我直接使用根父类来声明呢?
使用 Collection<String> names = new ArrayList<>();
这样的声明方式是完全有效的,而且确实可以进一步提高代码的灵活性,因为 Collection
是所有集合类的顶级接口。但是,这样做也有一些潜在的缺点:
-
方法可用性限制:
Collection
接口提供了集合的基本操作,如add
,remove
,contains
,isEmpty
,size
等,但它并不包含List
接口中特有的方法,如get(int index)
,set(int index, E element)
, 或者add(int index, E element)
。如果你使用Collection
作为类型,你将无法访问这些List
特有的方法,这可能会限制你对集合的操作能力。 -
类型信息丢失:虽然使用
Collection
可以让你在需要时替换为任何Collection
的实现,但这也意味着你放弃了List
或Set
等特定集合类型的信息。这可能使得代码阅读者需要额外的工作来理解集合的预期行为。 -
编译器警告:在某些情况下,如果你使用
Collection
声明的变量试图调用List
特有的方法,编译器会发出警告,提示你可能的类型不匹配。
在实践中,通常建议使用最具体的接口类型来声明集合变量,比如 List<String>
或Set<String>
,因为这提供了更多类型安全性和方法可用性。然而,如果你确实需要一个高度灵活的容器,或者你不确定未来是否会改变集合的类型,使用Collection
也是可以的。但是,你必须确保你的代码仅依赖于Collection
接口提供的方法,否则你将需要进行强制类型转换,这可能会带来类型安全风险。
5. Map 的代码示例
Map<String, Object> data = new HashMap<>();
这行代码定义了一个名为data
的变量,它是一个Map
类型的实例,其中键(key)的类型是String
,值(value)的类型是Object
。具体解析如下:
-
Map<String, Object>
:这是变量data
的类型声明,说明data
是一个映射(map),它将String
类型的键映射到Object
类型的值。在Java中,Map
是一个接口,它定义了键值对集合的行为。 -
new HashMap<>()
:这是创建一个新的HashMap
实例。HashMap
是Map
接口的一个实现类,它提供了基于哈希表的映射,提供了较快的存取速度。<>
内部的String, Object
是类型参数,指定了HashMap
中键和值的类型。 -
泛型与类型推断:使用
<>
内的类型参数,我们可以明确指定HashMap
中键和值的类型。在Java 7及以上版本中,由于类型推断的存在,new HashMap<>()
可以省略类型参数(即String, Object
),编译器会从左边的类型声明Map<String, Object>
中推断出类型参数。
通过这行代码,你可以创建一个HashMap
实例,用于存储和管理键为String
,值为Object
的键值对。例如,你可以像下面这样使用data
:
data.put("name", "John Doe");
data.put("age", 30);
data.put("isStudent", false);
String name = (String) data.get("name");
Integer age = (Integer) data.get("age");
Boolean isStudent = (Boolean) data.get("isStudent");
需要注意的是,因为值的类型是Object
,所以在获取值时可能需要进行类型转换,如上述代码中的(String)
, (Integer)
, 和 (Boolean)
。
6. 通配符、extends 和 super
-
extends
: 当你使用extends
关键字时,你是在说这个类型通配符可以匹配指定类的任何子类(包括指定的类本身)。这通常用于生产者场景,即一个方法可能返回一个不确定的子类型。 -
super
: 相反,super
关键字用于消费者场景,它指定了类型通配符可以匹配指定类的任何超类(包括指定的类本身)。这意呸着你可以将任何类型的对象放入一个被声明为超类的对象中。
public class GenericExample {
// 使用 extends 限定类型通配符,这个泛型的类型只能是 Number 或 Number 的子类
public static void printNumbers(List<? extends Number> numbers) {
for (Number number : numbers) {
System.out.println(number);
}
}
// 使用 super 限定类型通配符,确保它是 Integer 或 CInteger 的父类
public static void addAll(List<? super Integer> list, List<Integer> items) {
for (Integer item : items) {
list.add(item);
}
}
public static void main(String[] args) {
List<Integer> intList = new ArrayList<>();
List<Double> doubleList = new ArrayList<>();
List<Comparable> compList = new ArrayList<>();
// 可以将 intList 或 doubleList 传递给 printNumbers 方法
printNumbers(intList); // OK
printNumbers(doubleList); // OK
// 不能将 compList 传递给 printNumbers,因为 Comparable 不是 Number 或其子类
// printNumbers(compList); // Error
// 可以将 compList 传递给 addAll 方法,因为 Integer 是 Comparable
addAll(compList, intList); // OK
// 不能将 intList 或 doubleList 传递给 addAll,因为它们不是 List<Comparable>
// addAll(intList, intList); // Error
// addAll(doubleList, intList); // Error
}
}
7. Java 泛型和 C++ STL
-
C++ STL (Standard Template Library):
- C++中的模板允许你编写能应用于多种数据类型的代码。
- 在编译时,模板会被实例化为特定类型的代码,这意味着对于每种数据类型,编译器都会生成对应的代码版本。
- 这导致了类型安全和潜在的运行时性能优势,因为编译器可以根据具体类型优化代码。
- 编译后的代码可能更大,编译时间也可能更长,因为需要为每个模板实例生成代码。
-
Java 泛型:
- Java泛型允许你编写类型安全的代码,该代码在编译时检查类型,但不生成特定类型的多个实例。
- 类型擦除意味着编译后的字节码不包含类型参数信息,所有泛型类型在运行时被视为其原始类型。
- 这减少了代码的膨胀和编译时间,但可能会限制某些运行时优化,因为类型信息在运行时不可用。
- Java泛型还包括类型通配符和类型边界等概念,以增加代码的灵活性。
参考链接