Java基础(一)

18 阅读18分钟

1. 数据类型

Java 语言有以下 4 类基本数据类型:

  1. 整数类型:有 4 种整型byte/short/int/long,分别有不同的取值范围。
  2. 小数类型:有 2 种小数类型float/double,分别有不同的取值范围和精度。
  3. 字符类型:char类型,表示单个字符。
  4. 真假类型:boolean类型,表示逻辑真假。

1.1. 整数类型

整数类型有byte/short/int/long四种,它们分别占据1/2/4/8个字节,取值范围如下表所示:

类型名字节数取值范围
byte1
short2
int4
long8
byte b = 23;
short s = 333;
int i = 9999;
long l = 3232343433L;

给整数类型变量赋值直接使用=就好,有一点需要注意,当给long类型变量赋值时若常量的值超过了int的范围需要在后面加上字母lL,因为整型常量的默认类型是int,会溢出。

1.2. 小数类型

小数类型有float/double四种,它们分别占据4/8个字节,取值范围如下表所示:

类型名字节数取值范围
float4-3.4E+38~-1.4E-45 ∪ 1.4E-45~3.4E+38
double8-1.7E+308~-4.9E-324 ∪ 4.9E-324~1.7E+308
double d = 333.33;
float f = 333.33F;

使用=直接给变量赋值,需要注意因为浮点数常量默认是double类型,所以给float类型赋值的时候若是常量越界了需要在后面加上fF

1.3. 真假类型

boolean b = true;
b = false;

真假类型使用关键字boolean,我们直接使用truefalse来赋值。

1.4. 字符类型

char c = 'A';
char z = '学';

字符类型使用char关键字,一个字符占 2 个字节,所以 Java 的字符变量里面即可以存英文字符也可以存中文字符。

1.5. 数组类型

数组类型的三种初始化形式:

  1. int[] arr = {1, 2, 3};``[]表示数组类型,我们直接使用{}给初值。
  2. int[] arr = new int[]{1, 2, 3};这种写法是上一种的完整版本。
  3. int[] arr = new int[3]; 定义长度为 3 的整型数组,不给初值。

前两种初值方法下,数组的长度由初值的个数决定,不能手动指定长度。最后一种方法只指定了数组长度,但并没有给初值,那么数组元素会有默认值:数字类型为 0,boolean类型为 false,字符类型为空字符。

和 C 不同,我们在定义数组的时候,数组长度可以使用变量指定:int n = 10; int[] arr = new int[n];。我们定义了一个数组,系统会为我们分配两段内存,一块是数组空间(存储数组元素的地方),一块是数组名所对应的空间(存储着数组空间的首地址)。所以本质上说,数组名存储的就是数组空间的首地址,也就是说我只要改变这个地址就可以让数组名指向另一段数组空间:

int[] a = {1, 2, 3};
int [] b = {4, 5, 6, 7};
a = b;

使用a = ba指向b的数组空间即{4, 5, 6, 7},也就是说此时ab的指向是一样。而原来a的数组空间要是没人引用就会被垃圾回收。

因此我们可以发现,一个数组名可以指向各种各样长度的数组空间。

2. 基本运算

2.1. 算术运算

Java 里面算术运算包含+, -, *, /, %,除此之外也有++, --。和 C 类似:

  1. /在整数之间表示整除(向 0 取整、舍弃小数位),有浮点数参与运算的时候才会表示精确除法。
  2. %只能用在整型或字符型之间,即小数不能进行除法。
  3. ++, --可以用在变量的前面或后面,主要体现在返回值不同。++在前就先+1再返回;++在后就先返回再+1

一些运算的注意事项:

// 结果太大了,int 无法存下
int a = 2147483647 * 2;

// long 可以存下,但下面代码还是不对
long a = 2147483647 * 2;

// 下面才是正确的写法
long a = 2147483647 * 2L;

// 为了避免整除,我们使用浮点操作数
double d = 10 / 4.0;
  1. 给变量赋值的时候不应该越界存储。
  2. 如左边第二种情况,变量类型long可以存下运算结果,但最终结果仍然不对。因为右边两个操作数全是int,所以计算的时候会将结果当做int,这个时候结果已经越界了,所以赋值给变量 a 的结果是越界后的结果。
  3. 因此我们使用2L作为操作数,这样结果就是long类型了。

2.2. 比较运算

Java 里比较运算包括>, >=, <, <=, ==, !=,所有数值和字符之间的比较规则和 C 是一样的。

数组类型进行比较,比较的当然是两者存储的地址。只有当两个数组变量指向同一块数组空间的时候才会返回true,否则都是返回false。想要比较两个数组存储的元素是否全都一样只能使用循环自行比较。

所有的比较运算返回值类型都是boolean,和 C 不一样的是 Java 只能使用truefalse表示真假,不能使用数字。

2.3. 逻辑运算

逻辑运算的运算对象只能是boolean类型,结果也是boolean类型。Java 的逻辑运算符有:

  1. &:有假则假,不会短路。
  2. |:有真则真,不会短路。
  3. !:真假取反。
  4. 异或^:两者不同为true,两种相同为false
  5. 短路与&&:有假则假,会短路。
  6. 短路或||:有真则真,会短路。

3. 分支结构

3.1. if分支

if (条件1) {
    语句块1
} 
else if (条件2) {
    语句块2
} 
... 
else {
    语句块n    
}

n 个分支的if选择结构执行流程:

  1. 自上而下判断条件,若判定为真则执行对应的语句块,整个流程结束。
  2. 若没有条件为真,则执行else代码块内容。
  3. else ifelse块可以省略掉。
  4. 语句块里只有一条语句的时候,可以省略大括号(不建议)。

对于简单的if else条件分支可以使用?:运算符来简写:逻辑表达式 ? 表达式1: 表达式2。这边逻辑表达式为真时会执行表达式1,否则执行表达式2。

3.2. switch分支

switch(表达式) {
    case1:
        代码块1
    case2, 值3:
        代码块2
    ...
    case 值n-1:
        代码块n-1
    default:
        代码块n
}

n 个分支的switch选择结构执行流程:

  1. 表达式的类型须为:byte、short、int、char、枚举和 String。
  2. 找到值与表达式值相同的case,执行这个case及之后的所有case
  3. 没有匹配的就执行default及其后面所有case
  4. 多个case对应同一个代码块的时候,可以合并成一个case,后面多个值使用逗号隔开。
  5. 可以使用break提前退出。

一般来说使用switch效率比if要高,因为if条件是从上到下一一进行判断,而switch使用跳跃表能够减少判定的次数。每一种case都是一个整数,String会被哈希成整数,我们会创建一个数组将所有的case值排序后存进去,然后查找的时候使用二分查找提高查找效率。如果case值是连续的或者近似连续的,那么优化后直接根据下标查找更快。

3.3. 增强switch

传统switch具有穿透性质,每次都要使用break很不方便。增强的switch使用函数式编程:

int dayOfWeek = 4;
String name = switch (dayOfWeek) {
    case 1 -> "星期一";
    case 2 -> "星期二";
    case 3 -> "星期三";
    case 4 -> "星期四";
    case 5 -> "星期五";
    case 6 -> "星期六";
    case 7 -> "星期七";
    default -> "Unknown";
};
System.out.println(name);
  1. 每一个case后面使用箭头,箭头后面使用一条语句或一个语句块。
  2. 增强的switch可以作为一个语句存在,此时它有返回值,可以定义变量接受。
  3. 增强switch没有穿透特性,对应分支执行完之后直接退出switch

4. 循环

Java 有四种循环,分别是whiledo...whileforforeach循环。前面三种和 C 是类似的:

int[] nums = {1, 2, 3, 4, 5};

int i = 0;
while (i < nums.length) {
    System.out.println(nums[i++]);
}

do {
    System.out.println(nums[i++]);
} while (i < nums.length);

// for 循环初始化表达式部分可以定义循环变量
for (int i = 0; i < nums.length; ++i) {
    System.out.println(nums[i]);
}

当我们需要遍历数组或集合元素的时候,往往使用foreach循环更简洁:

int[] nums = {1, 2, 3, 4, 5};
for (int num : nums) {
    System.out.println(num);
}
  1. foreach并不是关键字。
  2. 冒号前面的变量每次能取到一个元素直到所有元素都遍历结束。
  3. 冒号前面的变量必须在里面定义。

除了循环外,Java 也支持continuebreak进行循环控制。break表示直接退出循环,continue表示跳过本轮循环。

5. 函数

5.1. 样例

Java 函数要放在类里面,没有面向过程编程。

// 创建类,类名随意
public class Main {
    // Java 的入口函数 main
    public static void main(String[] args) {
        // 函数调用。我们可以定义和函数名一样的变量
        int sum = sum(1, 2);
        System.out.println(sum);
    }

    // 自定义的函数 sum,public static 是修饰符,其余和 C 一样
    public static int sum(int a, int b) {
        return a + b;
    }
}

5.2. 数组参数

函数的参数可以是一个数组,数组传递的时候传的是什么呢?很显然,数组名里面装的是什么就传什么,因此参数传递的是数组空间的首地址,从而在函数内部修改数组元素,外部是可见的。

public static void main(String[] args) {
    int[] sum = {1, 2, 3};
    change(sum);
    // 输出 9
    System.out.println(sum[0]);
}

public static void change(int[] nums) {
    nums[0] = 9;
}

5.3. 可变长参数

除此之外,Java 还支持可变长参数,可变长参数的语法是在参数类型后面加...。一个函数最多只能有一个可变长参数,且只能放在参数列表的末尾。实际上,会把可变长参数转变为一个数组,因此在函数内使用可变长参数和使用数组没有区别。使用可变长参数主要能简化代码书写。

// b 是可变长参数,在函数里面的使用和普通数组没区别,其实本质上就是数组
public static int max(int a, int... b) {
    for (int n : b) a = n > a ? n : a;
    return a;
}

// 调用函数,相当于调用了 max(3, new int[]{1, 2, 4})
int max = max(3, 1, 2, 4);

5.4. 返回值

Java 也是使用return关键字返回,返回值的类型需和函数类型一致,最多可以低精度转高精度,不会高精度转低精度。也就是说,函数类型是int,返回值类型是double是会报错的。函数内部可以有多个return,返回类型是void的也可以使用return;直接返回。

函数的返回值最多只有一个,但有时候我们需要返回多个值怎么办?例如传进去一个数组,返回数组里面最大值和最小值。

  1. 一种是使用成员变量返回(类似于 C 的全局变量)。
  2. 另一种方案是在函数内部创建一个数组,将要返回的值放到数组里面然后返回。

第二种方案要求变量的类型是一致的,而且和 C 不同的是函数内部创建的数组在退出函数的时候只会销毁数组名的空间,而数组空间不会销毁(没人引用会被垃圾回收),因此直接返回数组空间地址是可以的。

对于复杂的数据类型,我们可以创建类对象来返回。

5.5. 重载

Java 一个类中可以有多个同名的函数,但是这些函数的参数不能完全一致,要么至少一个参数类型有不同,要么参数数量不同。如下 Java 的Math 库里面就定义了下面 4 个求最大值函数:

public static double max(double a, double b)
public static float max(float a, float b)
public static int max(int a, int b)
public static long max(long a, long b)

在函数重载的情况下,我调用这个函数,那么会匹配到哪一个呢?

答:会匹配到最接近的。例如我调用上面求最大值函数,参数是两个char,那么会优先匹配到参数是int的函数,其次是longfloatdouble。注意,匹配到时候只会上升精度匹配,不可能降低精度匹配。也就是说函数参数类型是int,实参是double将会无法匹配。说到底,就是系统不会做将高精度转低精度的强转,但这样的强转我们可以手动做(int) 3.14

5.6. 函数执行原理

程序从main函数开始执行,遇到函数调用就跳转到被调函数的代码处继续执行,执行完毕后重新回到主调函数的下一条语句处执行。在这个过程中,有以下几个问题:

  1. 函数调用的参数是怎么传递的?
  2. 被调函数是如何知道执行完毕之后该回到哪里去的?
  3. 被调函数通过return返回值是怎么给到主调函数的?

我们首先要知道计算机里面存储分为:栈、堆、返回值存储区(这个区域比较复杂,我们暂且认为就是一块存储返回值的内存)。栈里面主要存储函数里面定义的局部变量、形参等,堆主要存储数组空间以及对象空间(对象和数组类似将内存分两个部分,一个是变量名存储对象空间所在地址,一个就是对象空间存储对象里面的数据内容),返回值存储区临时存储函数的返回值。

我们使用一个例子感受一下函数调用时栈的状态:

public class Main {

    public static int sum(int a, int b) {
        int c = a + b;
        return c;
    }

    public static void main(String[] args) {
        int d = Main.sum(1, 2);
        System.out.println(d);
    }
}

这是一个简单的例子,我们下面阐述一下流程:

  1. 主函数执行函数sum之前会为形参args和局部变量d在栈里分配内存,如图1-1。
  2. 函数sum被执行期间,先为形参ab在栈里分配内存。
  3. 紧接着就是将主调函数的下一条指令地址存进栈里,然后为局部变量c分配内存并计算结果存进去。
  4. 遇到return语句,先将返回内容c的值存进返回值存储区,整个栈图如图1-2。
  5. 被调函数的内容全部出栈(销毁),然后根据指令地址转到主调函数,从返回值存储区读取返回值赋值给变量d

图1-1

图1-2

从流程我们发现,对于返回值我们会将其存到一个专门的地方,然后给到主调函数。这样做是因为变量c在函数结束的时候就被销毁掉了,没办法取值,所以找了一个中间商。

这样就会产生一个问题:我们知道异常处理的时候finally块一定会执行。假设有c = 1,函数里面有return c;,又在finally里面执行了c++;那么最终返回结果是什么?

答:返回 1,因为遇到return的时候会将c变量的值备份到返回值存储区,接着执行c++影响不到返回值。

当我们将数组作为参数的时候,传递数组空间的首地址。看下面的案例(对应栈图1-3):

public class Main {

    public static int max(int min, int[] arr) {
        int max = min;
        for (int a: arr) {
            if (a > max) max = a;
        }
        return max;
    }

    public static void main(String[] args) {
        int []arr = {2, 3, 4};
        int ret = max(0, arr);
        System.out.println(ret);
    }
}

图1-3

最后提一句,函数栈的存储是从高位往低位存储的,即栈底在高位的内存,栈顶在低位内存(简记为从右到左存储)。

6. 二进制

6.1. 整数

Java 整数有byte/short/int/long四种,这 4 种整数的性质完全一样,只是可表示范围不一样,我们使用byte举例子。一个byte有 8 位,每位有01两种取值,其中最高位(最左边位)表示符号位,取值1表负数,取值0表正数。

  1. 正数使用原码表示法,即二进制正数与以下十进制数是等价的:

例如:0111表示十进制数7010表示十进制数2

  1. 负数使用补码表示法,即先得出对应的正数的原码,接着对原码所有位取反,最后再+1

这样做的原因也很简单,我们知道相对的两个正数、负数之和为0。我们得出正数的原码,那什么数与之相加和为0呢?先对原码取反(0 变 1,1 变 0),这样两者之和所有位全部是1,在此基础上只要再+1就会变成全0。因此正数对应的负数是对原码取反再+1,那么同样的逻辑负数想变成对应的正数也做一样的操作取反再+1

于是很经典的一幕来了:127 + 1 = -128

127  --> 01111111
1    --> 00000001
+ ----------------
-128 --> 10000000

不难看出,最大的正数加一之后会变成最小的负数。闭环了有没有?

除了二进制,比较常用的进制还有十六进制。二进制转十六进制很简单,直接 4 位一组转成十六进制即可。例如-128 = 0b10000000 = 0x80,其中0b <=> 0B表示二进制常量的前缀,0x <=> 0X是十六进制常量的前缀。在 Java 里面我们可以

String Integer.toBinaryString(int a)  // int 转二进制字符串
String Integer.toHexString(int a)  // int 转十六进制字符串
String Long.toBinaryString(long a)  // long 转二进制字符串
String Long.toHexString(long a)  // long 转十六进制字符串

6.2. 位运算

位运算有移位运算和逻辑运算,分别见下面:

  1. &按位与 AND,有 0 则 0。
  2. |按位或 OR,有 1 则 1。
  3. ~表示按位取反,0 变 1,1 变 0。
  4. ^表示异或,两者不一样的为 1,否则为 0。
  5. >>>无符号右移,使用 0 填补左边空位。
  6. >>有符号右移,使用符号位填补左边空位,算术上x>>n等价于,这边的除法表示整除。
  7. <<左移,左移时用 0 填补右边空位,算术上x<<n等价于

下面程序展示了使用byte作为位集的操作:

// 整数转二进制输出
public static void printb(int b) {
	System.out.println(Integer.toBinaryString(b));
}

public static void main(String[] args) {
    byte x = 1 << 1 | 1 << 5;
    byte y = 1 << 1 | 1 << 2;
    
    printb(x);  // "00100010" 集合 {1, 5}
    printb(y);  // "00000110" 集合 {1, 2}
    printb(~x);  // "11011101" 取补集 1 - x = {0, 2, 3, 4, 6, 7}
    printb(x & y);  // "00000010" 取交集 x ∩ y = {1}
    printb(x | y);  // "00100110" 取并集 x ∪ y = {1, 2, 5}
    printb(x ^ y);  // "00100100" 取对称差 x + y - xy = {2, 5}
    printb(x & ~y);  // "00100000" 取差集 x - y = {5}
    printb(x << 1);  // "01000100" 集合 {2, 6}
    printb(x >>> 1);  // "00010001" 集合 {0, 4}

    // 判断 x 哪些位是 1
    for (int i = 0; i < 8; i++) {
        if ((x & (1 << i)) != 0)
            System.out.printf("%d", i);  // 15
    }
}

6.3. 小数

浮点数在计算机的存储是按照科学记数法存储的,只不过底数为 2。也就是所有浮点数都会转为的形式,这边 m 称为尾数,e 称为指数。很多小数没法表示成这样的写法,所以会导致结果不精确。

了解了浮点数在内存的存储形式,那么具体存储方案是什么呢?

  1. 对于 32 位浮点数(float),1 位表示正负,23 位表示尾数,8 位表示指数。
  2. 对于 64 位浮点数(double),1 位表示正负,52 位表示尾数,11 位表示指数。

6.4. 字符

字符使用char关键字,本质上char就是占据两个字节的无符号正整数。这个正整数对应Unicode编码,这是兼容ASCII编码的。因为两个字节能表示只有 65536 以内的字符,所以编码超出的字符需要其它方式表示。不过好在大多数常用汉字在表示的范围内。

char c = '卢';  // 直接赋值字符常量
char c = 21346;  // 使用十进制编码赋值
char c = 0x5362;  // 使用十六进制编码赋值
char c = '\u5362';  // 使用 Unicode 方式赋值