ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java基础进阶:数据类型、数组、枚举与面向对象实战

Java基础进阶:数据类型、数组、枚举与面向对象实战 干了十年Java说实话最怕的不是框架升级而是新人把基础学了个囫囵吞枣。系列前两篇我们聊了环境配置和基本语法这一篇直接聊几个“看起来简单、实际上全是坑”的知识点数据类型、数组、排序、枚举顺带把面向对象的第一步迈出去。这几个东西几乎每天都会碰到但很多人写了一年代码仍然说不清基本类型和引用类型在内存里到底差在哪也不明白数组越界为什么防不胜防。这篇不画大饼全部用实际场景说话。1. 先搞定Java数据类型别让内存细节拖后腿1.1 基本类型和引用类型的本质分界Java的数据类型分两大类基本类型和引用类型。基本类型有8个byte、short、int、long、float、double、char、boolean。引用类型包括类、接口、数组还有一个特别的存在——枚举后面单独说它本质上也是类。关键区别在存储方式。基本类型的变量栈上直接存“值”引用类型的变量栈上只存“地址”真正的对象实例在堆里。你可以这样理解基本类型是你直接在记事本上写下数字“10”引用类型是你在记事本上写了一个抽屉编号真正的物品在编号指向的柜子里。用代码看会更清楚int a 10; int b a; b 20; System.out.println(a); // 输出 10 int[] arrA {1, 2, 3}; int[] arrB arrA; arrB[0] 99; System.out.println(arrA[0]); // 输出 99a和b互不影响因为它们是两份独立的“值”。arrA和arrB指向的是同一个堆对象所以一个改另一个跟着变。这个特性如果没理解透后面写方法传参时会吃到大量苦头。1.2 数值选型与溢出陷阱选类型不是随意写的它背后是内存占用和数值范围。byte占1字节取值-128到127short占2字节int占4字节long占8字节。float和double都是浮点类型但精度完全不同。我在面试中见过太多人栽在溢出上。看这个例子int max Integer.MAX_VALUE; // 2147483647 max max 1; System.out.println(max); // -2147483648正数加1变成负数新手看到会怀疑人生。原因很简单int用二进制补码表示最高位是符号位加1后所有位全部翻转为最小负数的表示。这不是bug是数学规则。实战建议涉及金额、订单号、时间戳这类可能增长到很大值的数据直接上long别用int硬扛。涉及小数运算且对精度有要求的别用double——0.1加0.2输出0.30000000000000004这是二进制浮点表示的天生缺陷你用float和double都绕不开。表基本数据类型速查类型字节数默认值数值范围速记byte10-128 ~ 127short20-32768 ~ 32767int40约正负21亿long80L极大日期时间戳常用float40.0f约7位有效数字double80.0d约15位有效数字char2\u00000 ~ 65535boolean视JVM实现falsetrue / false我在实际项目中处理过一次金额累计的bug日流水用int存结果上线半个月就溢出成负数差点酿成事故。从那以后凡是有增长预期的数值我一律建议用long起步这个习惯能救你很多次。2. 标识符命名规则早建立早受益2.1 官方规则与规范速记标识符就是给类、方法、变量取的名字。官方规则有硬性四条只能由字母、数字、下划线、美元符号组成不能以数字开头不能是Java关键字不能是true、false、null。另外Java大小写敏感Name和name是两个完全不同的标识符。这里有个冷知识Java的“字母”是支持Unicode的也就是说理论上你可以用中文当变量名。但我的建议非常明确不要用。不是技术不允许而是团队协作时中文标识符在部分构建工具、编码格式不一致的场景下会出现乱码或兼容问题。我在给一家外贸公司做代码review时见过用中文当变量名的项目一旦文件编码从UTF-8被改成GBK整个文件直接乱掉排查成本高到离谱。规范层面行业通行做法是类名大驼峰如OrderService、方法名小驼峰如getUserName、常量全大写下划线分隔如MAX_ORDER_COUNT、包名全小写。这套规范不是官方强制的但几乎所有Java项目都在用随便打开一个开源项目翻一下就知道了。2.2 命名时的实用建议与反例命名也是有“手感”的好的命名让代码像在读文章差的命名让人想摔键盘。我给几个我在review代码时最常看到的反例String s; // 谁看谁知道是什么 ListOrder list; // 光看名字不知道是订单列表还是别的 int temp getResult(); // temp这种名字毫无信息量 boolean flag true; // 看到flag我一般都很慌都不知道它表达什么含义我自己的习惯是“按意图命名而不是按内容命名”。比如最多订单数不叫orderList叫maxOrderCount判断是否登录返回的boolean不叫flag叫isLoggedIn。命名时敲键盘才几个字符但阅读成本是长期的。越早建立命名意识后面维护代码时越轻松。另外我强烈推荐团队里统一使用阿里巴巴开发手册里那套命名规约虽然有些条目感觉是废话比如“禁止使用拼音命名”但踩过坑的人都知道拼音缩写比英文缩写更难猜DZJZ这种变量名是订单净值还是注册净值神仙都猜不出来。遇见过一次就能记一辈子。3. 数组、越界异常与拷贝这关逃不掉3.1 数组在JVM里的真实布局数组在Java里是被特殊对待的引用类型。声明int[] arr new int[5];栈上的arr指向堆内存中一块连续空间这块空间里按顺序存放着5个int值。索引从0开始所以一个长度为5的数组合法索引是0到4。初学者最容易混淆的是length不是方法而是属性。写字符串时是str.length()写数组时是arr.length。我在带实习生时几乎每周都能看到一个报错在数组上写arr.length()或者拿数组去调size()方法。这两个方法一个是String的一个是集合的数组的length长得又像变量又像方法确实容易混但多写几次就记住了。创建数组的三种常见方式int[] arr1 new int[3]; // 默认值0后面再赋值 int[] arr2 new int[]{1, 2, 3}; // 显式初始化 int[] arr3 {1, 2, 3}; // 语法糖声明时必须同时赋值3.2 数组越界到底是怎么发生的数组越界异常全名叫ArrayIndexOutOfBoundsException它会出现在你想访问的索引小于0或者大于等于数组长度的场景。下面这种代码就是典型int[] scores {85, 90, 78}; for (int i 0; i scores.length; i) { System.out.println(scores[i]); // i3时越界 }注意问题出在遍历数组应该是。这种错误我在线上排查过不止一次大多数时候不是语法问题而是边界条件理解错误。另一个常见越界场景是表格型循环二维数组行数和列数搞混或者从某个下标开始遍历时终点计算写错。我自己的排查经验是看到越界异常先别急着盯代码先打印数组长度和当前循环变量值往往一眼就能看出差一个的位置关系。记得有一次我排查一个同学的项目他说数组长度是10却越界我让他打印了才知道他初始化时长度写的是0个元素——对new的时候长度写错成0后面想塞10个数据进去自然立刻爆炸。3.3 深浅拷贝一个必须亲手试的实验数组拷贝是个高频操作但深浅拷贝的分寸很多人是模糊的。先看最基础的三招int[] src {1, 2, 3}; // 方式一clone int[] copy1 src.clone(); // 方式二System.arraycopy int[] copy2 new int[src.length]; System.arraycopy(src, 0, copy2, 0, src.length); // 方式三Arrays.copyOf int[] copy3 Arrays.copyOf(src, src.length);这三种方式对基本类型数组都能实现“真拷贝”改copy里的值不会影响src。但换成对象数组呢比如Person[]这三种方式都只能是“浅拷贝”新数组里存的是旧数组里那些对象的引用而不是对象的副本。这意味着如果你修改copy[0].name原来数组的src[0].name也会变因为两个数组里的0号位置指向同一个Person对象。要想真正深拷贝对象数组只能自己循环遍历每个元素手动创建新对象再赋值。如果要拷贝的对象层级特别深稳妥做法是配合序列化实现整棵对象树的复制但要注意被拷贝类必须实现Serializable接口并且这种方式的性能开销比手动循环大得多。我在项目里就吃过浅拷贝的亏。一次是在配置中心拉取一批规则对象出于省事直接用了clone()去复制结果某个线程改了其中一个规则对象的状态所有线程都跟着变了。那一次之后我养成了一个习惯凡是拷贝引用类型数组第一反应是问“这里面元素会不会被修改”只要会就老老实实手动循环深拷贝。表数组拷贝方式对比方式基本类型数组对象数组(浅)使用场景建议clone()真拷贝浅拷贝简单复制不常修改System.arraycopy真拷贝浅拷贝需要控制拷贝区间Arrays.copyOf真拷贝浅拷贝常用首选可扩容手动循环new真拷贝深度拷贝对象需独立修改时4. 冒泡排序手写实录从能跑到优雅4.1 冒泡排序的直觉冒泡排序是排序算法里最符合直觉的一个。核心思路一句话每一轮从头到尾两两比较把较大的元素往后挪就像气泡一样一点一点浮到最终位置。每轮结束后当前范围最大的数会被“冒泡”到最右边下一轮就可以少比较一个位置。动笔之前先想清楚两个量外层循环控制轮数n个元素最多n-1轮内层循环控制每一轮比较到哪里为止第i轮结束后有i个元素已经在正确位置所以内层只需要比较到n-1-i这个下标位置。4.2 三版代码的演进思路版本一先跑通绝不炫技public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }这个版本能跑但内层每次都跑到n-1白做了很多无效比较。比如第一轮结束后最大的数已经确定在最右边下一轮就不该再和它比较了。于是有了版本二。版本二缩小比较范围public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }版本二已经是教科书标准写法了。但仔细想一下如果数组本来就是有序的比如{1,2,3,4,5}版本二依然会傻傻地跑完全部轮次一次交换都不发生。能不能在这一轮没有交换时提前结束这就是“哨兵变量”的用途。版本三加交换检测public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { boolean swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } if (!swapped) { break; } } }加了一行布尔判断在大多数部分有序的场景下能省掉大量无意义的遍历。这段代码不是最优的排序实现但作为学习材料非常合适它展示了“在正确性之上逐步思考效率”的演进路径这是写所有代码都通用的思维方式。4.3 手写排序之外的工程选择强调一下我上面花这么多篇幅讲手写冒泡不是为了让你在生产环境用。工程上排序直接用标准库就好数组用Arrays.sort()集合用Collections.sort()或list.sort()。Java的Arrays.sort()在底层对基本类型用双基准快速排序对对象数组用TimSort一种稳定的归并排序变体性能和稳定性都远比自己手写靠谱。但为什么还要学手写两个原因。一是面试和考试场景里手写排序几乎是保留节目蓝桥杯这类竞赛的基础题也经常涉及理解冒泡的思路会对后面学更复杂的快速排序、归并排序有很大铺垫作用。二是手写能力代表你是否真正理解“数据怎么在内存里移动”这在调试排序相关bug时特别有用。我见过一个同事数组一直排不对后来发现是交换逻辑里下标写错把arr[j]换到了arr[i]上这就是基础不够扎实造成的反面教材。5. 枚举类型的正确姿势5.1 枚举究竟解决了什么问题枚举是Java 5加入的语法本质是一个特殊的类。它解决的最核心痛点是一类固定不变的常量如果没有类型约束代码会上演一场灾难。想象一下你写了个订单状态字段直接用整数表示0是待支付1是已支付2是已取消。看起来没什么问题但代码里到处都是if(status 0)这种魔法数字时间一长没人知道0到底代表什么。后来有人改用字符串常量结果因为大小写不一致找bug找到崩溃。枚举把这类常量变成“类型安全的对象”从根上消灭了这个问题public enum OrderStatus { PENDING_PAYMENT, PAID, CANCELLED }使用时直接OrderStatus status OrderStatus.PAID;编译器帮你检查合法性不再可能出现status 3这种没意义的判断。这就是选择枚举而不是普通常量的核心逻辑它不只约束取值范围还让代码语义清晰到不需要额外注释。5.2 带字段、带方法的枚举才是完整形态很多人学了枚举只用它的最简形式当常量用。但枚举真正厉害的地方在于可以带字段、构造方法和方法。最常见的场景是给状态配一个描述文本public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), CANCELLED(2, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }这里有个细节必须强调枚举的构造器不允许用public修饰只能私有或者什么都不写包私有因为枚举实例是在类加载时由JVM创建的外部不能自己new。你如果尝试加一个public的构造器编译器直接报错。我还经常看到有人用枚举写状态机的流转逻辑比如“待支付”只能流转到“已支付”或“已取消”。这是可行的在枚举内部加一个方法传目标状态并做合法性校验public boolean canTransitionTo(OrderStatus target) { switch (this) { case PENDING_PAYMENT: return target PAID || target CANCELLED; case PAID: return target CANCELLED; // 已支付可以取消退款 case CANCELLED: return false; default: return false; } }枚举还提供了两个内置方法values()返回所有枚举常量数组valueOf(String)按名称找常量。配合switch语句用起来非常顺滑switch (status) { case PAID: // 处理已支付逻辑 break; case CANCELLED: // 处理取消逻辑 break; default: throw new IllegalArgumentException(未知状态: status); }我在做订单模块时曾经用枚举给几十个错误码配了对应的提示文本和HTTP状态码一个类搞定所有映射关系后续加新错误码只需要在枚举里多一行改起来特别清爽。这就是工程化的力量——同样的功能用普通常量加if-else也能做但代码量和维护成本完全是两个量级。6. 面向对象从这里起步6.1 类、对象与内存的对应关系面向对象这个概念被讲得太玄乎了。用大白话来说类是图纸对象是按照图纸造出来的实体。图纸定义了属性字段和行为方法而实体占据内存拥有自己的属性值。同一个类可以创建多个对象它们的方法代码是共享的但各自持有的数据是独立的。创建对象的过程new背后做了三件事在堆内存中分配空间、调用构造方法初始化字段、把内存地址返回给栈上的引用变量。很多人只记住了new没意识到它在内存层面干了这么多事导致后面学垃圾回收时一脸懵。一个非常经典的对比public class Dog { String name; int age; void bark() { System.out.println(name 在叫...); } } Dog dog1 new Dog(); dog1.name 旺财; Dog dog2 dog1; dog2.name 来福; System.out.println(dog1.name); // 来福这里的dog2 dog1并没有创建新对象只是让dog2也指向了同一个堆对象。所以赋值语句执行完后dog1和dog2是同一只狗的两个名字而已。这和前面数组的例子是同一个原理如果你看明白了这个部分面向对象的入门就完成了一大半。6.2 先把“封装”这个基本功练扎实面向对象三大特性封装、继承、多态。我见过太多人一上来就研究多态的虚方法表结果连封装都做不好。我的建议是先把封装练到肌肉记忆。封装的核心是把字段设置为private对外提供受控的访问方法。为什么要这么做考虑一个场景一个订单的金额字段如果直接public外部代码可以随手赋个负数然后整个系统的统计全乱。但如果它是private外部只能通过setter赋值你可以在setter里加校验public class Order { private double amount; public void setAmount(double amount) { if (amount 0) { throw new IllegalArgumentException(订单金额不能为负); } this.amount amount; } public double getAmount() { return amount; } }有了这道闸门任何非法数据都在进来的时候被挡住而不是等到整个系统跑完才发现数值不对。这就是封装解决的实际问题保护内部状态不被外部随意破坏。它还带来了一个附加好处就是将来你想改字段存储方式比如从double改成BigDecimal做精确计算所有调用方都不需要改因为读写入口没变。我在初学阶段曾经很不理解为什么setter里明明可以return;跳过去还要写throw。后来线上出过一次数据幽灵问题就是在静默吞掉非法参数的场景下发生的——你把负数“忽略”了订单金额从0开始累计数据完全对不上还没有任何报错。从那以后我特别坚定非法参数要么抛异常要么明确地校验后拦截千万不能静默处理。这是用真金白银换来的教训。6.3 继承和多态的一点点预告继承和多态是接下来的重头戏但这里先建立直觉模型。继承是“is-a”关系比如猫是一种动物表达式是Cat extends Animal。子类继承父类的字段和方法同时可以重写Override父类的不满意的方法。重写不是随便写的方法名、参数列表必须完全一致返回值类型是父类返回值的子类型还会要求访问权限不能比父类更严格。重写时需要加Override注解这几乎是Java界不成文的规定。它的作用不是给注释看的而是让编译器帮你检查。如果你本来想重写却写成了重载方法签名没对上加了Override注解IDE会直接报错能提前挡住一个隐蔽bug。我在给团队定规矩时明确要求所有重写方法强制加注解没有例外。多态的基础是向上转型。Animal a new Cat();引用类型是Animal对象类型是Cat。调用a.speak()时实际执行的是Cat重写后的speak方法虽然编译时a看起来是Animal类型。这个“看到的是父类跑的是子类”的机制是JVM运行期动态绑定的结果。不用急着背术语先记住这个行为模式后面会反复遇到包括框架源码里到处都是这种设计。结束一些掏心窝的话写到这里从数据类型到数组从冒泡排序到枚举再从封装到多态的雏形把Java入门路上最容易“看起来懂了实则没懂”的坎儿都过了一遍。我个人在带新人和面试候选人的过程中最深的体会是基础知识的熟练度不靠背靠动手和复盘。数据类型的内存差异我建议你写个小程序亲自验证一遍数组越界和深拷贝建议你故意写错几次观察异常信息长什么样冒泡排序从版本一改到版本三建议你断点追踪每一轮swap发生的位置。这些动作都不难但完成后的手感是看多少篇文章都换不来的。如果这一篇的内容你都能独立讲明白那我觉得Java基础这条路你已经走得相当扎实了下一步可以放心接触集合框架和异常体系。最后再分享一个小技巧在你自己的项目里每遇到一次报错就花五分钟把报错信息的关键词复制出来搜索一次把它记录的典型原因整理成自己的错题集这比刷十遍面经都管用。
返回列表