这是一个技术疯狂迭代的时代,各种框架层出不穷,然而底层基础才是核心竞争力。博主(小牛肉)在现有的知识基础上,以上帝视角对 Java 语言基础进行复盘,汇总《Java 小白成长记》系列,力争从 0 到 1,全文无坑。
? 本章你将了解在 Java 中万物皆对象的思想,掌握 Java 程序的基本组成
计算机革命的起源来自机器。编程语言就像是机器。它不仅是我们思维放大的工具与另一种表达媒介,更像是我们思想的一部分。语言的灵感来自其他形式的表达,如写作,绘画,雕塑,动画和电影制作。编程语言就是创建应用程序的思想结构。
面向对象编程(Object-Oriented Programming,OOP)是一种编程思维方式和编码架构,是一种 对现实世界理解和抽象的方法,是计算机编程技术发展到一定阶段后的产物。
? 什么是对象:对象是客观存在的事物,可以说任何客观存在的都是可以成为对象,一台电脑,一直钢笔,一个人,一辆轿车等等,都可以称为对象
⭐ 面向对象的基本特点:
在面向对象之前,比如 C 语言就是面向过程编程(Procedure-Oriented Programming,POP)的,这两者的区别就在于:
? 举个形象的例子来区分 OOP 和 POP:有一天你想吃红烧牛肉了,你有两个选择
但是如果你突然不想吃红烧牛肉了,想吃 xxxx,对于面向过程来说,你还需要重新买菜重新做。而面向对象只需要跟老板说把红烧牛肉换成 xxxx 就可以了。
这就是两者的区别了,饭店/老板就是一个黑盒子,我们要什么直接去拿就好了,不用我们自己一步一步的去实现它。
在 C/C++ 中,对象的操纵是通过指针来完成的。
⭐ 在 Java 中,一切都被视为对象,但操纵的标识符实际上是对象的一个引用(reference)。
举个形象点的例子:我们可以用遥控器(引用)去操纵电视(对象)。只要拥有对象的“引用”,就可以操纵该“对象”。换句话说,我们无需直接接触电视,就可通过遥控器(引用)自由地控制电视(对象)的频道和音量。此外,没有电视,遥控器也可以单独存在。就是说,你仅仅有一个“引用”并不意味着你必然有一个与之关联的“对象”。
? 比如:下面来创建一个 String
引用,用于保存单词或语句
String s;
这里创建的只是引用,并不是对象。如果此时对 s
应用 String
方法,会报错。因为此时 s
没有与任何事物相关联。因此,一种安全的做法是:创建一个引用的同时便进行初始化:
String s = "小牛肉";
不过这里用到了 Java 语言的一个特性:字符串可以用带引号的文本初始化。
可以显式地将对象变量设置为 null
,表明这个对象变量目前没有引用任何对象。
String s = null;
如果将一个方法应用于一个值为 null
的对象上,那么就会产生运行时错误。
通常我们使用 new
操作符来创建一个新对象:
String s = new String("小牛肉");
new
关键字的意思就是 “给我一个新对象”。new
操作符的返回值也是一个引用
可以理解为 new String("小牛肉")
构造了一个 String
类型的对象, 并且它的值是对新创建对象的引用。这个引用存储在对象变量 s
中。
也可以让某个对象变量引用一个已存在的对象:
String str = new String("小牛肉");
String s;
s = str
现在,这两个变量 s
和 str
引用同一个对象 String
。
⭐ 通过上图,大家应该能明白:在 Java 中,任何对象变量的值都是对存储在另外一个地方的一个对象的引用
以上展示了字符串对象的创建过程,以及如何初始化生成字符串。当然,关于
String
的知识点非常之多,后续的博文中会陆续讲解。
除了 String
类型以外,Java 本身也自带了许多现成的数据类型,我们还可以创建自己的数据类型。事实上,这是 Java 程序设计中的一项基本行为。
? 本部分内容会在 JVM 系列详细讲解
那么,程序在运行时是如何存储的呢?尤其是内存是怎么分配的。有 6 个不同的地方可以存储数据::
1) ? 寄存器(Registers):最快的存储区,位于处理器内部。数量有限且我们无法直接控制,也无法在自己的程序里找到寄存器存在的踪迹(另一方面,C/C++ 允许开发者向编译器建议寄存器的分配)
2)? 栈(Stack):位于通用 RAM(随机访问存储器)中,可通过栈指针获得处理器的直接支持。堆栈指针下移,则分配新的内存;若上移,则释放那些内存。这是一种快速有效的分配存储方法,仅次于寄存器。
创建程序时,Java 系统必须知道存储在堆栈内的所有项的确切生命周期,以便上下移动指针。这一约束限制了程序的灵活性,所以 Java 对象并不存储在此。
Java 的对象引用(变量名)和基本数据类型都存储在栈中。比如:
int a = 3;
? 编译器首先会在栈中创建一个变量名为 a 的引用,然后查找有没有字面值为 3 的地址,没找到,就在栈中开辟一个地址存放 3 这个字面值,然后将引用 a 指向 3 的地址
3)? 堆(Heap):一种通用的内存池,位于 RAM 中,用于存放所有的 Java 对象(new 出来都存在堆中)。堆不同于堆栈的好处就是:编译器不需要知道存储的数据在堆里存活多长时间。比如:
String str1 = new String("小牛肉");
? 对于上面这条语句,new String("小牛肉")
表示创建了一个对象,这个对象存放在堆内存中,我们用一个引用 str1 来指向这个对象的地址,这个对象的引用 str1 存放在栈内存中
?
String str2 = "小牛肉";
,"小牛肉"
会存放在字符串常量池(String Pool)中:
"小牛肉"
存储在堆内存关于字符串部分的知识,我们后续会专门开篇文章讲解,此处大家有个概念就好。
4)? 常量存储(Constant storage):存放字符串常量和基本类型常量 public static final
,这样做是安全的,因为它们永远不会被改变。比如
public static final int HELLO = 3;
? 对于上面这条语句,3 作为基本类型常量存放于常量存储,HELLO 作为变量名存放于栈内存中
5)? 静态存储(Static Storage):存放静态成员(static
)包括静态成员变量和静态成员方法(类方法)
6)? 非 RAM 存储:如果数据完全存活于程序之外,那么它可以不受程序的任何控制,在程序没有运行时也可以存在。比如流对象和持久化对象。
有一组类型在 Java 中使用频率很高,它们需要特殊对待,这就是 Java 的基本类型。之所以这么说,是因为它们的创建并不是通过 new
关键字来产生。通常 new
出来的对象都是保存在堆内存中的,以此方式创建小而简单的变量往往是不划算的。所以对于这些基本类型的创建方法,Java 使用了和 C/C++ 一样的策略。也就是说,不是使用 new
创建变量,而是使用一个“自动”变量。这个变量直接存储"值",并置于栈内存中,因此更加高效。
Java 是一种强类型语言。这就意味着必须为每一个变量声明一种类型(Python 就是弱类型语言)。
Java 确定了每种基本类型的内存占用大小。这些大小不会像其他一些语言那样随着机器环境的变化而变化。这种不变性也是 Java 更具可移植性的一个原因。
基本类型 | 大小 | 最小值 | 最大值 |
---|---|---|---|
boolean | — | — | — |
char | 16 bits | Unicode 0 | Unicode 216 -1 |
byte | 8 bits | -128 | +127 |
short | 16 bits | - 215 | + 215 -1 |
int | 32 bits | - 231 | + 231 -1 |
long | 64 bits | - 263 | + 263 -1 |
float | 32 bits | IEEE754 | IEEE754 |
double | 64 bits | IEEE754 | IEEE754 |
void | — | — | — |
整型用于表示没有小数部分的数值, 它允许是负数。Java 提供了 4 种整型,具体内容如 表 3-1 所示:
? 在 Java 中, 整型的范围与运行 Java 代码的机器无关(平台无关性)。这就解决了软件从一个平台移植到另一个平台,或者在同一个平台中的不同操作系统之间进行移植给程序员带来的诸多问题。与此相反,C 和 C++ 程序需要针对不同的处理器选择最为高效的整型, 这样就有可能造成一个在 32 位处理器上运行很好的 C 程序在 16 位系统上运行却发生整数溢出。由于 Java 程序必须保证在所有机器上都能够得到相同的运行结果, 所以各种数据类型的取值范围必须固定。同样的,由于这个原因 ,Java 中也没有 sizeof
。
长整型数值有一个后缀 L 或 1 ( 如 4000000000L) 。十六进制数值有一个前缀 0x 或 0X (如 0xCAFEL 八进制有一个前缀 0 , 例如, 010 对应八进制中的 8。很显然, 八进制表示法比较容易混淆, 所以建议最好不要使用八进制常数。
从 Java 7 开始, 加上前缀 0b 或 0B 就可以写二进制数。例如,0b1001 就是 9。另外,同样是 从 Java 7 开始,还可以为数字字面量加下划线,如用 1_000_000 表示一百万。这些下划线只是为了提高可读性,Java 编译器会去除这些下划线。
? 在 C 和 C++ 中, int 和 long 等类型的大小与目标平台相关。在 8086 这样的 16 位处理器上整型数值占 2 字节;不过, 在 32 位处理器(比如 Pentium 或 SPARC) 上, 整型数值则为 4 字节。类似地, 在 32 位处理器上 long 值为 4 字节, 在 64 位处理器上则 为 8 字节。由于存在这些差别, 这对编写跨平台程序带来了很大难度。在 Java 中, 所有的数值类型所占据的字节数量与平台无关。 注意, Java 没有任何无符号(unsigned) 形式的 int、 long、short 或 byte 类型。
浮点类型用于表示有小数部分的数值。在 Java 中有两种浮点类型,具体内容如表 3-2 所示:
double
表示这种类型的数值精度是 float
类型的两倍(有人称之为双精度数值)。绝大部分应用程序都采用 double
类型。在很多情况下,float
类型的精度很难满足需求。实际上,只有很少的情况适合使用 float
类型,例如,需要单精度数据的库, 或者需要存储大量数据。
float
类型的数值有一个后缀 F 或 f (例如,3.14F) 。没有后缀 F 的浮点数值(如 3.14 ) 默认为 double
类型。当然,也可以在浮点数值后面添加后缀 D 或 d (例如,3.14D) 。
所有的浮点数值计算都遵循 IEEE 754 规范。具体来说,下面是用于表示溢出和出错情况的三个特殊的浮点数值:
Double_POSITIVE_INFINITY
Double.NEGATIVEJNFINITY
Double.NaN
例如, 一个正整数除以 0 的结果为正无穷大。计算 0/0 或者负数的平方根结果为 NaN
? 特别要说明的是, 不能这样检测一个特定值是否等于Double.NaN
:
if (x == Double.NaN) // is never true
所有“ 非数值” 的值都认为是不相同的。然而,可以使用 Double.isNaN
方法:
if(Double.isNaN(x))
? 浮点数值不适用于无法接受舍入误差的金融计算中。例如,命令
System.out.println ( 2.0-1.1 )
将打印出 0.8999999999999999, 而不是人们想象的 0.9。这种舍入误差的主要原因是浮点数值采用二进制系统表示, 而在二进制系统中无法精确地表示分数 1/10。这 就好像十进制无法精确地表示分数 1/3 —样。如果在数值计算中不允许有任何舍入误差, 就应该使用BigDecimal
类, 本章稍后将介绍这个类。
char
类型的字面量值要用单引号括起来。例如:'A'
是编码值为 65 所对应的字符常量。它与 "A"
不同,"A"
是包含一个字符 A 的字符串, char
类型的值可以表示为十六进制值,其范围从 \u0000
到 \Uffff
。
除了转义序列 \u
之外, 还有一些用于表示特殊字符的转义序列, 请参看表 3-3:
boolean (布尔)类型有两个值:false
和 true
, 用来判定逻辑条件。? 整型值和布尔值之间不能进行相互转换。
? 在 C++ 中, 数值甚至指针可以代替 boolean 值。值 0 相当于布尔值 false, 非 0 值相当于布尔值 true, 在 Java 中则不是这样。因此, Java 程序员不会遇到下述麻烦:
if (x = 0) // oops... meant x = 0
在 C++ 中这个测试可以编译运行, 其结果总是 false;
而在 Java 中, 这个测试将不能通过编译, 其原因是整数表达式 x = 0 不能转换为布尔值。
如果基本的整数和浮点数精度不能够满足需求, 那么可以使用 java.math
包中的两个很有用的类:Biglnteger
和 BigDecimal
这两个类可以处理包含任意长度数字序列的数值。Biglnteger
类实现了任意精度的整数运算,BigDecimal
实现了任意精度的浮点数运算。
使用静态的 valueOf
方法可以将普通的数值转换为大数值:
BigInteger a = BigInteger.valueOf(100);
遗憾的是,不能使用人们熟悉的算术运算符(如:+
和 *
) 处理大数值。而需要使用大数类中的 add
和 multiply
方法。
Biglnteger c = a.add(b); // c = a + b
Biglnteger d = c.multiply(b.add(Biglnteger.valueOf(2))); // d = c * (b + 2)
关于这两个类的详细信息,请参考 JDK 官方文档。
在 Java 中,有 2 种标记注释的方式。
//
,其注释内容从 //
开始到本行结尾/*
开头,可以跨越多行,到 */
结束。一般大家都习惯在多行注释的每一行开头添加 *
,所以你经常会看到:
在一些编程语言中,管理变量的生命周期需要大量的工作。一个变量需要存活多久?如果我们想销毁它,应该什么时候去做呢?本节向你介绍 Java 是如何通过释放存储来简化这个问题的。
作用域决定了在其内定义的变量名的可见性和生命周期。在 Java、C++、C 中,作用域由花括号 { }
的位置决定。例如:
{
int x = 12;
// Only x available
{
int q = 96;
// Both x & q available
}
// Only x available
// q is out of scope
}
不过,需要注意的是,以下代码在 C/C++ 中是合法的,但是在 Java 中不能这样写:
{
int x = 12;
{
int x = 123; // 非法
}
}
? 在 C/C++ 中,将一个较大作用域的变量隐藏的做法,在 Java 里是不允许的。
Java 对象不具备和基本类型一样的生命周期。当用 new
创建一个 Java 对象时,它可以存活于作用域之外。比如:
{
String s = new String("aas");
}
对象的引用 s
在作用域终点就消失了。然而,s
指向的 String
对象仍占据内存空间。我们无法在作用域之后访问这个对象,因为对他唯一的引用已经超出了作用域的范围。
Java 有一个垃圾回收器,用来监视 new
创建的所有对象,并辨别那些不会被再引用的对象,然后释放这些对象的内存空间。
如果一切都是对象,那么是什么决定了某一类对象的外观和行为呢?换句话说,是什么确定了对象的类型?
大多数面向对象的语言都使用 class
关键字类来描述一种新的对象。通常在 class
关键字的后面的紧跟类的的名称。如下代码示例:
class ATypeName {
// 这里是类的内部
}
在上例中,我们自定义了一个新的类型 ATypeName
,尽管这个类里只有一行注释。但是我们一样可以通过 new
关键字来创建一个这种类型的对象。如下:
ATypeName a = new ATypeName()
到现在为止,我们还不能用这个对象来做什么事(即不能向它发送任何有意义的消息),除非我们在这个类里定义一些方法。
当我们创建好一个类之后,我们可以往类里存放两种类型的元素:方法(method)和字段(field)。类的字段可以是基本类型,也可以是引用类型。如果类的字段是对某个对象的引用,那么必须要初始化该引用将其关联到一个实际的对象上(通过之前介绍的创建对象的方法)。每个对象都有用来存储其字段的空间。通常,字段不在对象间共享。下面是一个具有某些字段的类的代码示例:
class DataOnly {
int i;
double d;
boolean b;
}
? 变量名对大小写敏感, 例如,
hireday
和hireDay
是两个不同的变量名 , 在对两个不同的变量进行命名时, 最好不要只存在大小写上的差异。
这个类除了存储数据之外什么也不能做。但是,我们仍然可以通过下面的代码来创建它的一个对象:
DataOnly data = new DataOnly();
我们必须通过这个对象的引用来指定字段值。格式:对象名称.方法名称或字段名称
。代码示例:
data.i = 47;
data.d = 1.1;
data.b = false;
如果类的成员变量(字段)是基本类型,那么在类初始化时,这些类型将会被赋予一个初始值。
基本类型 | 初始值 |
---|---|
boolean | false |
char | \u0000 (null) |
byte | (byte) 0 |
short | (short) 0 |
int | 0 |
long | 0L |
float | 0.0f |
double | 0.0d |
? 注意:这些默认值仅在 Java 初始化类的时候才会被赋予。这种方式确保了基本类型的字段始终能被初始化,从而减少了 bug 的来源。但是,这些初始值对于程序来说并不一定是合法或者正确的。所以,为了安全,我们最好始终显式地初始化变量。
这种默认值的赋予并不适用于局部变量 —— 那些不属于类的字段的变量。因此,若在方法中定义的基本类型数据,如下:
int a;
System.out.println(a);
这里的变量 a 不会自动初始化为0,因而在使用变量 a 之前,必须用赋值语句对变量进行显式初始化, 千万不要使用未初始化的变量。否则,Java 将会提示我们“编译时错误,该变量可能尚未被初始化”。
在 Java 中,我们使用术语 方法(method)或 函数 (function) 来表示“做某事的方式”。
在 Java 中,方法决定对象能接收哪些消息。方法的基本组成部分包括名称、参数、返回类型、方法体。格式如:
[返回类型] [方法名](/*参数列表*/) {
// 方法体
}
方法的返回类型表明了当你调用它时会返回的结果类型。参数列表则显示了可被传递到方法内部的参数类型及名称。方法名和参数列表统称为方法签名(signature of the method)。签名作为方法的唯一标识。
Java 中的方法只能作为类的一部分创建。它只能被对象所调用,并且该对象必须有权限来执行调用。若对象调用错误的方法,则程序将在编译时报错。
我们可以像下面这样调用一个对象的方法:
[对象引用].[方法名](参数1, 参数2, 参数3);
假设一个对象引用 a
的方法 f
不带参数并返回 int
型结果,我们可以如下表示:
int x = a.f();
上例中方法 f
的返回值类型必须和变量 x
的类型兼容 。调用方法的行为有时被称为向对象发送消息。面向对象编程可以总结为:向对象发送消息。
方法参数列表指定了传递给方法的信息。参数列表必须指定每个对象的类型和名称。
int storage(String s) {
return s.length() * 2;
}
这里,返回值是通过计算
s.length() * 2
产生的。在方法中,我们可以返回任何类型的数据。如果我们不想方法返回数据,则可以通过给方法标识 void
来表明这是一个无需返回值的方法。代码示例:
void nothing() {
return;
}
当返回类型为 void
时,return
关键字仅用于退出方法,因此在方法结束处的 return 可被省略。
重点来了!!!
首先回顾一下在程序设计语言中有关将参数传递给方法(或函数)的一些专业术语。
⭐ Java 程序设计语言总是采用按值调用。也就是说,方法得到的是所有参数值的一个拷贝,方法不能修改传递给它的任何参数变量的内容。
假定一个方法试图将一个参数值增加至 3 倍:
public static void tripleValue(double x){
x *= 3;
}
--------------------------
double percent = 10;
tripleValue(percent);
调用这个方法之后,percent
的值还是 10。下面看一下具体的执行过程:
percent
值的一个拷贝(也就 是 10 )percent
仍然是 10? 方法参数共有两种类型:
已经看到,一个方法不可能修改一个基本数据类型的参数。而对象引用作为参数就不同了,可以很容易地修改对象的字段值(比如我们有个 Employee
类,其中有字段 salary
和 方法 raiseSalary
):
class Employee {
private salary;
......
public void raiseSalary {
salary += 200;
}
}
-----------------------
public static void tripleSalary (Employee x) {
x.raiseSalary(200);
}
-----------------------
harry = new Emplyee(...);
tipleSalary(harry);
具体的执行过程为:
harry
值的拷贝,这里是一个对象的引用。raiseSalary
方法应用于这个对象引用。x 和 harry
同时引用的那个 Employee
对象的薪金提高了 200。harry
继续引用那个薪金增加 200 的对象。? 下面总结一下 Java 中方法参数的使用情况:
? C++ 有值调用和引用调用。引用参数标有 &
符号。例如, 可以轻松地实现 void tripleValue(double& x)
方法或void swap(Employee& x, Employee& y)
方法实现修改它们的引用参数的目的。
int swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
这种在 C++ 中的常见写法在 Java 中是错误的