
写Java的第一周我印象最深的不是Hello World跑通的兴奋而是第一次被编译器的报错信息按在地上摩擦。明明照着书本敲的代码为什么提示illegal start of expression为什么变量名换个名字就编译通过了后来才明白这些看似零碎的语法点——标识符、常量与变量、数据类型、运算符与表达式——才是后续所有面向对象代码的地基。这篇文章把这部分内容做一个系统梳理结合我在课程作业和实际开发里踩过的坑说清楚每个语法背后的逻辑而不只是把规则背一遍。无论你是刚接触Java的初学者还是上过课但对某些细节模棱两可都可以对照这篇文章重新过一遍。1. 标识符这一关命名不对代码从第一行就开始埋雷很多教材讲标识符只给三条规则不能以数字开头、不能是关键字、由字母数字下划线美元符组成。规则背完就过去了但实际写代码时真正让人翻车的往往是规则之外的那些边界情况。1.1 Java标识符的合法性边界Java标识符的正式定义是由字母、下划线_、美元符$开头后续字符可以加数字字母范围包括Unicode字符。这意味着中文变量名在Java里是合法的——int 年龄 20;能编译通过。但为什么我劝你不要这么写不是语法不允许而是编码环境和团队协作的锅。很多IDE默认用UTF-8没问题但项目切换GBK编码时中文标识符直接乱码代码直接废掉。这种合法但不安全的命名属于给自己埋雷。真正严格的约束是保留字。Java有50多个关键字class、public、static、void这些都不能作为标识符。容易忽略的是true、false、null这三个——它们在语言规范里不叫关键字叫字面量但同样不能用作标识符。你去写int true 1;编译器一样报错。有意思的是C语言里经常报未定义的标识符true是因为C语言没有原生的布尔常量true是需要引入stdbool.h头文件才有的宏定义。Java不存在这个问题因为Java把true和false做成了boolean类型的字面量属于语言内置。这两种设计背后是两种思路C追求轻量、能省则省Java追求状态明确、拒绝歧义。还有一个细节是$。$在Java标识符里是合法的但不要主动用因为Java编译器在生成内部类时比如Outer$1这种匿名内部类会自动使用$符号你自己命名的类如果也带着$出现同名冲突时排查起来极其痛苦。我在一次作业里见过有同学定义变量my$score编译运行都没问题但后面跟匿名内部类一结合报了一堆诡异错误查了一晚上才发现是命名规范的问题。1.2 命名规范不是形式主义是降低沟通成本语法规则保证能不能编译命名规范保证人能不能看懂。Java领域普遍接受的是驼峰命名法类名用大驼峰StudentInfo方法名和变量名用小驼峰studentName、getScore常量全大写加下划线MAX_SCORE。这些不是Java语法要求的但你要进入真实项目就必须遵守。我见过太多新手把变量写成a、b、c或者用拼音缩写xueshengmc。自己刚写完还能记得这是什么三个星期后回头看完全不知道这段代码在干什么。命名这件事我个人的做法很简单变量名宁可长一点也要把含义表达清楚。int totalScore永远比int ts好读。这不仅是给队友看的更是给未来的自己看的。代码写出来首先是给人读的其次才是让机器执行。2. 变量和常量搞懂存储盒子的开关逻辑标识符给数据起好了名字接下来就要搞清楚数据本身怎么放、怎么改、怎么定死。这一节把变量和常量的底层逻辑讲透后面写类的时候你才知道它们应该待在哪。2.1 变量声明的四个要素声明一个变量本质上是向编译器申请一块内存区域并约定这块区域里只能放某种类型的数据。四个要素缺一不可数据类型、变量名、初始值、作用域。int count 10; // 数据类型int变量名count初始值10作用域是当前代码块 double price; // 只声明不赋值后面再给值这里最坑的是局部变量必须初始化。看这个例子int x; if (flag) { x 1; } System.out.println(x); // 编译报错可能尚未初始化变量x为什么Java强制局部变量初始化因为编译器是保守的它无法在编译期确定flag到底是不是true为了保证安全干脆要求你给一个默认值。这里有同学会问那成员变量为什么不强制初始化因为成员变量有默认值——int默认为0double默认为0.0boolean默认为false。这是Java语言规范规定的成员变量在对象创建时由JVM统一分配零值。理解了这一点你就能记住一个结论局部变量无默认值不初始化就用不了成员变量有默认值但不初始化容易留下赋值遗漏的隐患。2.2 常量final修饰的变量和字面量不一样Java里常量分两种一种是直接写在代码里的字面量比如int a 10;中的10就是int类型的字面量另一种是用final修饰的变量一旦赋值就不可修改。final double PI 3.14159; // PI 3.14; // 编译报错final变量不能被重新赋值写作业时很多人觉得反正我没改它加不加final无所谓。但final的真正价值是表达设计意图告诉读代码的人这个值是定死的不允许任何人碰。常量还有一个好处是消灭魔法值。比如计算圆的面积直接用3.14写进表达式这个3.14不知道哪来的定义成final double PI 3.14159;语义一目了然。以后想提高精度只改一处就可以。还有一类容易忽略的是编译期常量。当final修饰的变量值在编译期就能确定Java编译器会把它直接视为字面量在编译阶段就做代入。final int MAX 100;这个MAX在编译期就替换成100了。而final int MAX getMax();属于运行期常量运行时才计算。两者的区别在处理大量常量的性能敏感场景下会体现出来。2.3 变量的生命周期和作用域别把能访问当还活着作用域决定变量在哪些地方能用生命周期决定变量在内存里待多久。局部变量和成员变量的生命周期差异直接关系到内存的分配区域。局部变量创建在栈上方法执行完就销毁。成员变量跟随对象创建在堆上对象不再被引用时才被垃圾回收器回收。这个知识点现在可能感受不深但学到面向对象的多线程和内存模型时你会反复跟它打交道。作用域上最容易犯错的是同名变量遮蔽int n 10; if (true) { int n 20; // 编译报错变量n已在当前方法中定义 System.out.println(n); }Java不允许在同一作用域内出现同名变量但允许内层作用域声明和外层同名的变量这种情况叫遮蔽。遮蔽会让代码可读性急剧下降因为你很难判断当前操作的到底是谁。我写代码时基本靠一条纪律尽量把变量的作用域缩小到最小代码块同时避免重名遮蔽。这比记一百条规则都管用。3. 数据类型8种基本类型和引用类型的存储差异Java宣称自己是强类型语言意思就是所有变量都必须先定类型再用。理解数据类型的核心不只是记住那8种基本类型而是搞清楚类型之间的边界和转换规则。3.1 8种基本类型的精确边界与存储规格先上一张表把基本类型的关键参数列清楚类型字节数取值范围默认值byte1-128 ~ 1270short2-32768 ~ 327670int4-2147483648 ~ 21474836470long8-9223372036854775808 ~ 92233720368547758070Lfloat4约 ±3.4E387位有效数字0.0fdouble8约 ±1.8E30815位有效数字0.0dchar20 ~ 65535\u0000boolean不固定true / falsefalse这张表里有几个细节值得展开。byte范围为什么是-128到127因为一个字节8位最高位是符号位剩余7位表示数值最大是2^7 - 1 127负数范围多一个是-128。这些计算看着无聊但理解二进制补码之后你就能明白为什么int溢出会变成负数——不是乱报错而是二进制位模式刚好变成负数了。char是无符号的16位类型范围0到65535。它和C语言里的char完全不同C的char本质是个8位整数Java的char就是Unicode字符。在Java里你可以写char c 65;它和char c A;是等价的因为65正好是A的Unicode码点。这就为后面学字符与整数之间的隐式转换埋下伏笔。boolean类型比较特殊它只有true和false两个值而且Java不允许int和boolean直接互转。如果你从C语言转过来习惯写if (1)在Java里直接编译报错必须写成if (true)。Java这样设计是为了避免C语言中非0即真带来的模糊性让条件判断的状态更明确。这种强制安全感的取舍是Java一以贯之的设计哲学。3.2 自动类型提升与强制类型转换的精度陷阱类型的边界搞清楚之后最实际的问题就是不同类型的数据在一起运算时到底按谁的规则来自动类型提升的规则是小类型向大类型转。确切地说byte、short、char做运算时会自动提升为int如果表达式中有long整个提升为long如果有float提升为float如果有double提升为double。这意味着一个经典坑byte a 10; byte b 20; byte c a b; // 编译报错不兼容的类型从int转换为byte可能有损失很多人不理解10加20等于3030在byte范围内为什么还报错因为a b在编译器的眼里是两个byte先提升成int再做加法结果是int类型。int类型赋值给byte需要强制缩小转换编译器不允许因为它无法证明每一次运算结果都在byte范围内。解决办法要么写成byte c (byte)(a b);要么直接声明成int。这个细节在写数组下标、循环变量时非常容易踩到。强制类型转换的坑就更直接了——丢失精度。看这个例子int n 300; byte b (byte) n; // 结果是44300转成byte为什么是44byte只占8位int的300在二进制里是00000000 00000000 00000001 00101100强制截断后只保留最低8位00101100即十进制的44。这不是编译器在捣乱而是截断式保存的必然结果。所以强转之前你得先问自己三个问题这个值真的超范围了吗超了会引发什么后果有没有替代方案3.3 浮点数为什么double算钱会翻车float和double都是浮点数内部用IEEE 754标准表示采用符号位指数位尾数位的存储方式。这就导致一个反直觉的结论浮点数无法精确表示所有的十进制小数。写这段代码double total 0.1 0.2; System.out.println(total); // 输出 0.30000000000000004你期待的0.3实际输出0.30000000000000004。原因是0.1和0.2的二进制表示本身就是无限循环的浮点数只能保存近似值误差在运算中积累最后暴露出来。所以涉及金额计算别用double用BigDecimal或者用整型存分。课程作业里很多同学用double算购物总价结果得出的58.99999999999999被老师扣分这就是典型的类型选错。3.4 字符和字符串引号的差别不是美观问题char c A;和String s A;一个是基本类型一个是引用类型。这个区别在内存上差异巨大char占2个字节存的直接就是数值String是一个对象内部还要管理字符数组。新手最容易犯的错误是把字符串和字符混用char c A; String s A; System.out.println(c 1); // 输出66char参与算术运算了 System.out.println(s 1); // 输出A1字符串拼接同样是运算符char因为被当作数值参与加法得到了66String则触发拼接。想判断一个字符是不是字母可以用Character.isLetter(c)想判断一个字符串是不是包含一个字符用s.indexOf(c)。这俩方法别搞混否则你会在判空和字符处理上绕很远的弯路。4. 运算符与表达式优先级和求值顺序让想象与实际脱节表达式看起来是小学数学但Java的运算符规则要比数学里的四则运算复杂得多。我把最容易出问题的几个点拆开讲。4.1 自增自减和复合赋值Java有精确的求值规则i和i的区别教材上都写了i先返回原来的值再自增i先自增再返回新值。但真正做题时连着好几个自增自减写在一起很多人就懵了。看这个表达式int i 3; int j i i i--; System.out.println(i); // ? System.out.println(j); // ?我来拆解Java的运算从左到右逐步求值i返回当前值3然后i变成4i先把i从4加到5然后返回5i--返回当前值5然后i变成4。所以j 3 5 5 13执行完之后i 4。这里有一个关键认知自增自减操作的是变量本身返回的可能是旧值也可能是新值但副作用一定在求值完成后生效。理解了这句话不管表达式写多长你都能手算出来。不过真要写业务代码我不建议写这种一行三连的表达式可读性太差review时会被同事喷。但课程作业要考你必须会拆。复合赋值运算符也有隐藏的规则short s 1; s s 1; // 编译报错 s 1; // 编译通过这种行为上的差异是因为复合赋值运算符会隐式做一次类型强转等价于s (short)(s 1)。所以能编译通过不代表它没问题只是编译器帮你补上了强转。这个知识在你看一些老代码时会用到看到b 1要敏感地意识到背后藏了一个强转。4.2 短路逻辑和||的懒带来的性能收益和隐藏风险和||被称为短路运算符因为它们的求值是偷懒的左边能决定结果时右边根本不会执行。if (a ! null a.length() 0) { ... }比如判空再取长度a为null时第一个条件为false短路发生后a.length()不会被调用因此不会抛空指针异常。这是短路算法最有价值的应用。但短路也可能藏bug。如果你在右边的表达式里做了赋值操作int x 0; if (x 0 x 1) { } System.out.println(x); // 输出0x根本没执行左边x 0为false右边x被跳过x还是0。你要是以为x变成了1调试时就会怀疑人生。所以我的经验是表达式的副作用尽量单独写不要嵌在if条件的短路逻辑里。4.3 优先级和结合性的实际排序问题Java运算符优先级从高到低大概可以归成九层但真正写代码时你不需要背全表只需要记住三个关键规则圆括号永远是最高优先级拿不准就加括号赋值运算符优先级极低几乎总是最后执行算术运算的优先级高于关系运算关系运算高于逻辑运算。举一个日常会踩的坑int score 85; // 想判断成绩是否在[90, 100]区间 if (90 score 100) { ... } // 编译报错这是很多从Python转过来的同学最爱犯的错。Python允许链式比较Java不允许。Java必须写成if (score 90 score 100)。原因在于Java的是一个二元运算符90 score返回的是booleanboolean再去和100比较没有定义该运算所以编译直接报错。还有一个高频歧义点到底做加法还是字符串拼接一句话就能说清楚如果两边都是数值类型就是加法如果其中任何一边是String就变成字符串拼接。拼接顺序是从左到右的。于是就有了这种经典输出System.out.println(1 2 abc); // 3abc System.out.println(abc 1 2); // abc12第一种先算1 2 3然后3 abc拼成3abc第二种第一个就已经是拼接了后面全是拼接。做题时先找String出现在哪里这题的答案基本就出来了。4.4 三元运算符的类型统一问题三元运算符condition ? expr1 : expr2看着简单但它有一个隐藏规则两个分支的表达式类型必须统一编译器会自动做类型提升。int a 1; double b 2.0; Object o true ? a : b; // 结果是Double不是Integer System.out.println(o.getClass()); // class java.lang.Double因为a和b一个是int一个是double三元表达式会统一成double然后自动装箱成Double对象。这在泛型和反射场景下会让新手特别困惑因为打印出的是Double而不是Integer。记住这条规则三元运算符的两个分支如果有数值类型差异会先做类型提升再计算。5. 把基础语法串起来一个课程作业的完整拆解语法点一个个讲完最后用一道典型的课程作业把它们串起来判断一个三位数是不是水仙花数。水仙花数的定义是各个位上的数字的立方和等于它本身例如153 1^3 5^3 3^3。public class NarcissisticNumber { public static void main(String[] args) { int number 153; int hundreds number / 100; // 百位 int tens number / 10 % 10; // 十位 int units number % 10; // 个位 int sum hundreds * hundreds * hundreds tens * tens * tens units * units * units; if (sum number) { System.out.println(number 是水仙花数); } else { System.out.println(number 不是水仙花数); } } }这道题把这一章的知识全部用上了NarcissisticNumber是合法且规范的标识符number、hundreds是int变量153是int字面量常量/和%是算术运算符sum number是关系运算符和表达式。拆解一下几个易错点。第一取十位时为什么要/ 10 % 10以153为例153 / 10 1515 % 10 5正好是十位。如果你写number % 100 / 10153 % 100 5353 / 10 5也可以。两种写法都对关键是理解/和%的分工/是整除取商%是取余数。第二计算立方时为什么不能写hundreds * hundreds * hundreds以外的形式有人会想用Math.pow(hundreds, 3)。Math.pow返回的是double和int类型的sum做赋值时会发生从double到int的强制转换有精度丢弃风险。用三次乘法虽然啰嗦但类型完全可控课程作业里更倾向于这种写法。第三if (sum number)里sum和number都是int比较没有问题。如果你定义number为double再把153.0放进去比较结果也能工作但浮点数建议不要用精确比较因为精度问题可能让本应相等的两个值不相等。这是整个章节中选对数据类型的最典型案例。这道题的完整流程走一遍你会发现基础语法不是死记硬背而是在选类型—定名字—写表达式—做判断这条链路里自然的出现。每个环节都有对应的语法规则而每一条规则背后都有一个避免歧义、保证安全的理由。把这些理由想明白Java基础语法这一关就稳了。最后再说一个我自己的习惯每学完一节语法就手写一套变量声明表达式计算的小练习不用IDE的自动补全强制自己记住类型转换和运算符优先级。这个笨办法在后来写复杂算法题时帮了大忙——不需要查表就能一眼看出表达式的结果定位bug的速度快了好几倍。基础语法的练习就像练武之人的马步看起来枯燥但决定了后面能走多远。