前阵子有位读者给我发来一段代码,说自己的Java程序跑不起来,还把报错界面截了图,代码本身只有半行:
public class 文章标题输出{public static void main(Stri
很明显,代码被截断了,main(Stri后面应该是String[] args)。但这半截骨架特别有意思:它恰好把Java入门阶段最核心的几个关键词全带出来了——public、class、main、String。我面试过不少人,能把这一行解释透彻的,连三成都不到。多数人背得出“main方法是程序入口”,但再追问一句“为什么必须是public static void”,就卡壳了。
这篇就把这个被截断的代码当作引子,从类结构、main方法、String,一直讲到编译运行和面试高频坑。适合刚开始学Java的朋友,也适合准备Java基础面试、想系统过一遍知识点的读者。我讲得会比教科书啰嗦一些,但都是实际跑代码会踩到的经验和教训。
1. 类的基本结构:从public class说起
1.1 类名和文件名的硬性规则
先看代码里这段:public class 文章标题输出。Java规定:一个源文件里只能有一个public类,并且这个public类的名字必须跟文件名保持一致。也就是说,如果你写下public class 文章标题输出,那么这个源文件必须命名为文章标题输出.java,一个字母都不能差。
为什么会有这种看起来“无理”的要求?原因在于JVM(Java虚拟机)加载类的时候,是拿着类名去找.class文件的。你编译文章标题输出.java,编译器会生成一个文章标题输出.class的字节码文件。如果类名叫Foo,文件名却是Bar.java,编译时会直接报“类Foo是公共的,应在名为Foo.java的文件中声明”这样的错误。
这里建议不要用中文类名。虽然Java语法允许中文标识符,你写成文章标题输出编译没有问题,但在实际项目里,中文文件名、中文类名很容易在跨平台、跨编码环境时出乱子。我在Windows上就遇到过中文类名配合GBK编码,编译出来的字节码在其他平台执行时直接抛NoClassDefFoundError的情况。标准做法是使用英文大驼峰命名,比如ArticleTitlePrinter。另外,类名首字母大写,方法名首字母小写,变量名首字母小写,这是看得见的规范,面试也常问。
一个.java文件里其实可以定义多个类,但最多只能有一个public类。如果其他类不带public修饰符,它们就是包内可见的辅助类。这种写法在某些历史遗留代码里能看到,现在基本用内部类或者独立文件替代了。
1.2 public、private、protected:访问修饰符怎么选
代码里public是访问修饰符,控制这个类能被谁使用。Java一共有四种访问级别,我习惯用一张表说明:
| 修饰符 | 同类内 | 同包内 | 子类(不同包) | 所有类 |
|---|---|---|---|---|
| private | 可以 | 不可以 | 不可以 | 不可以 |
| 默认(什么都不写) | 可以 | 可以 | 不可以 | 不可以 |
| protected | 可以 | 可以 | 可以 | 不可以 |
| public | 可以 | 可以 | 可以 | 可以 |
为什么程序的入口类必须是public?因为JVM在启动时要调用这个类的main方法,调用者不在同一个包内,如果类不是public,JVM根本没有权限访问到这个方法所在的类。你可以把JVM想象成另一个包里的陌生人,它想用你写的类,你必须在门口挂上“对外开放”的牌子,而public就是这个牌子。
对于普通字段和方法,我的建议是:能private就private,需要让子类访问再考虑protected,对外暴露用public。很多新手图省事,把所有字段都写成public,这会让对象内部状态完全暴露,时间一长代码全是耦合,改一个字段名要牵连半个项目。
1.3 package与import:类的位置与相互引用
写了public类之后,还有个隐藏知识点是包。如果没有显式写package,这个类就位于默认包(default package)中。默认包只适合写练习代码,真实项目里必须给类分包,比如com.example.blog.util。包的作用相当于给类一个完整的“门牌号”,两个不同包下可以存在同名类。
当一个类想使用另一个包里的public类时,需要用import语句引入。比如你写的类在com.example包里,你想用java.util.Date,可以在文件顶部写import java.util.Date;,或者直接用全限定名java.util.Date date = new java.util.Date();。
这一节顺带提一个面试常见的Class<?>泛型符号。Class<?>中的问号是泛型通配符,表示“不知道具体类型,但确实是某种类型”。比如Class<?> clazz = obj.getClass();可以接住任意类型的Class对象,如果你写Class<String> clazz,就只能承接String的Class对象。这个知识点在反射章节会细说,先留个印象。
2. main方法:Java程序的真正入口
2.1 逐字拆解main方法的签名
看这个被截断的public static void main(Stri,补全之后是:
public static void main(String[] args)这四个单词加一个参数,每一个都不能少,少一个JVM都不认。我们从左到右逐一说明。
public我们前面讲过,是访问权限,保证JVM能够访问到这个方法。
static是静态修饰符,表示这个方法属于类本身,不需要创建对象就能调用。JVM启动时,还没有任何这个类的实例对象,如果main不是static,JVM就得先new一个对象出来才能调用入口方法,这会有个“鸡生蛋”的循环问题。所以java设计者直接规定main必须是static。
void表示没有返回值。main方法执行完,程序就结束了。如果你想在程序退出时返回一个状态码给操作系统,标准做法是调用System.exit(int),而main方法本身不返回任何值。
main是方法名,这个不能换。JVM加载类之后只认main(String[] args)这个特定签名。如果你把main拼成mian,或者参数写成String args(少了方括号),运行时会报:
错误: 在类 X 中找不到 main 方法, 请将 main 方法定义为: public static void main(String[] args)String[] args是方法参数,用来接收命令行传进来的字符串参数。数组的方括号可以写在变量后面,也就是String args[],这种写法语法合法,但可读性差,实际项目里几乎没人这么写。方法参数名可以改,叫args、argv、param都没问题,重要的是类型必须是String[]。
2.2 String[] args到底能干什么:命令行参数实战
这个参数不是摆设。Java程序启动时可以携带一串命令行参数,它们会以字符串数组的形式传入main方法。我举个例子,把标题里的类补成一个能接收文章标题并打印的程序:
public class ArticleTitlePrinter { public static void main(String[] args) { if (args.length > 0) { System.out.println("文章标题:" + args[0]); } else { System.out.println("请传入文章标题,例如:"); System.out.println("java ArticleTitlePrinter 我的标题"); } } }编译运行:
javac ArticleTitlePrinter.java java ArticleTitlePrinter "Java基础学习笔记"运行结果:
文章标题:Java基础学习笔记注意,args是一个数组,访问之前一定要先判断长度。直接写args[0],如果用户没传参数,程序会抛出ArrayIndexOutOfBoundsException,这是新手最容易踩的坑。
如果要遍历所有参数,可以这样:
for (String arg : args) { System.out.println(arg); }在IDEA里,你可以在Run Configuration的Program arguments栏里写上参数,相当于代替命令行传参。这在实际开发中很有用,比如很多工具类都通过main参数决定运行模式。
2.3 JVM如何找到main方法:启动流程与“找不到main”报错
JVM启动一个Java程序的流程大致是:先通过类路径找到指定的类文件,加载到内存,校验字节码,然后在这个类中查找签名固定的main方法。查找不到就直接报错。前面提到过,运行时报“找不到main方法”的最常见原因有三个:
第一,方法签名写错了。比如public static void main(String args),只写了一个String,没写方括号,编译能过,但运行时JVM不认。第二,main方法写到了其他类里,而你运行java 类名时指定了没有main方法的那个类。第三,main方法不是public或不是static,也会报错。
还有一种是IDE里常见的“编译器未包含main类型”。以Eclipse为例,当你选择一个没有main方法的类直接点Run As Java Application,IDE会提示“编辑器未包含main类型”。这不是Java编译器的问题,而是IDE检测到当前文件没有可运行入口。解决办法要么给当前类补上main方法,要么运行其他包含main的类。
JVM加载完类后,会先执行类中的静态代码块和静态变量初始化,然后才调用main方法。这一点很多人忽略,实际经常出现:静态初始化抛异常,导致Could not initialize class xxx,程序还没进main就崩了。比如下面这段写法:
public class SomeClass { static int value = 1 / 0; public static void main(String[] args) { System.out.println("不会执行到这里"); } }运行时会报ExceptionInInitializerError,底层原因是静态字段初始化时除零。排查这类问题,要去看静态块和静态变量的赋值逻辑,别盯着main方法找半天。
3. String:Java里最特殊的“非基本类型”
3.1 为什么String是大写,为什么不可变
Java的基本类型有8个:byte、short、int、long、float、double、char、boolean,它们全部是小写。而String是一个类,不是基本类型,所以它必须以大写开头。这个区别在面试里很常考:为什么Java的字符串不是基本类型?因为字符串需要大量的方法支持,比如截取、查找、替换,把它设计成类才合理。
String类还有一个关键特性:不可变(immutable)。一旦创建完成,这个字符串的内容就不可能被改变。你可能会有疑问:“我明明写String s = "hello"; s = s + " world";,s不是变了吗?”这里改的不是原字符串,而是创建了一个新的字符串对象"hello world",s变量只是改成了指向新对象。
为什么要设计成不可变?最直接的原因是安全。字符串经常用来当类名、文件路径、数据库连接地址,如果字符串可变,恶意代码就可能篡改这些值。另一个好处是缓存:同一个字符串字面量可以在字符串常量池中共享。比如:
String a = "hello"; String b = "hello"; System.out.println(a == b); // 输出 true,因为指向常量池同一个对象但如果用new:
String c = new String("hello"); System.out.println(a == c); // 输出 false,c是堆上新建的对象内容比较要用equals,不能用==。我见过太多新人在==这里翻车了,而字符串比较恰恰是Java面试的经典送分题。
3.2 String常用方法速查表
String类的方法很多,我不建议死记硬背,但下面这些是平时写代码和面试都高频出现的,整理成表格方便对照。
| 方法 | 作用 | 示例 |
|---|---|---|
| length() | 返回字符串长度 | "abc".length()得到3 |
| charAt(int index) | 返回指定位置字符 | "abc".charAt(1)得到'b' |
| substring(int beginIndex) | 从指定位置截取到末尾 | "hello".substring(2)得到"llo" |
| indexOf(String str) | 返回子串第一次出现的位置 | "hello".indexOf("l")得到2 |
| contains(CharSequence s) | 判断是否包含子串 | "hello".contains("ell")为true |
| replace(CharSequence old, CharSequence new) | 替换所有匹配子串 | "a-b-c".replace("-", "")得到"abc" |
| split(String regex) | 按正则拆分字符串 | "a,b,c".split(",")得到数组{"a","b","c"} |
| trim() | 去除首尾空格 | " hi ".trim()得到"hi" |
| toLowerCase() / toUpperCase() | 大小写转换 | "Ab".toLowerCase()得到"ab" |
| equals(Object obj) | 内容比较 | "abc".equals("ABC")为false |
| equalsIgnoreCase(String str) | 忽略大小写比较 | "abc".equalsIgnoreCase("ABC")为true |
| startsWith(String prefix) | 判断是否以指定前缀开头 | "hello".startsWith("he")为true |
| isEmpty() | 判断是否为空字符串 | "".isEmpty()为true |
| String.valueOf(Object obj) | 将对象转换为字符串 | String.valueOf(123)得到"123" |
使用split要特别小心,参数是正则表达式。如果你想用小数点来拆分字符串"a.b.c",直接写split(".")会得到空数组,因为.在正则里匹配任意字符。正确写法是split("\\.")。
3.3 StringBuilder与StringBuffer:字符串拼接的正确姿势
如果你在循环里用+拼接字符串,比如:
String result = ""; for (int i = 0; i < 100000; i++) { result += i; }这个写法非常低效。每次循环都会生成一个新的String对象,旧对象等着被垃圾回收。100000次循环会产生十万个中间字符串,耗时和内存损耗都很夸张。正确的替代方案是StringBuilder:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 100000; i++) { sb.append(i); } String result = sb.toString();StringBuilder内部用一个可变的字符数组,append只在数组尾部追加,不会不断产生新对象。实测中,同样是十万次拼接,String的耗时可能是StringBuilder的几十倍甚至上百倍。
StringBuilder和StringBuffer功能几乎一样,区别在于StringBuffer的方法加了synchronized关键字,是线程安全的,代价是性能略低一点点。单线程环境下直接用StringBuilder就行;多线程环境下如果有多个线程同时操作同一个字符串缓冲对象,才用StringBuffer。
关于两个缓冲类转成String,方法都是toString(),对应面试题里“stringbuffer转换为string”的答案。同时注意,StringBuilder和StringBuffer继承自同一个抽象父类AbstractStringBuilder,所以它俩不能直接强转,只能通过toString输出。
4. 编译与运行实操:让代码真正跑起来
4.1 完整代码示例:补全被截断的类
我把一开始那个截断代码补全成一个可运行版本。考虑到中文类名的不便,这里用一个英文类名:
/** 文章标题输出程序 */ public class ArticleTitlePrinter { public static void main(String[] args) { if (args.length == 0) { System.out.println("用法: java ArticleTitlePrinter <文章标题>"); return; } StringBuilder sb = new StringBuilder("文章标题:"); for (int i = 0; i < args.length; i++) { if (i > 0) { sb.append(" "); } sb.append(args[i]); } System.out.println(sb.toString()); } }这段代码把main接收到的所有参数用空格连起来输出。比如执行:
java ArticleTitlePrinter Java 基础 笔记输出:
文章标题:Java 基础 笔记编译时注意,如果源码文件用了UTF-8编码,在Windows命令行下可能需要指定编码:
javac -encoding UTF-8 ArticleTitlePrinter.java否则中文字符串可能变成乱码。这是JDK在不同平台上的老问题,实际项目里通常配合Maven或Gradle构建工具,它们默认会处理编码。早期我直接用记事本写Java,中英文混编时整过很多次乱码,后来一律用IDE的默认UTF-8,再也没出过问题。
4.2 常见报错与解决方法
这里把新手阶段常见的报错整理成一张速查表,每一条都是我现场踩过或者帮人排查过的。
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
| 错误: 找不到或无法加载主类 | 类名拼写错误、java命令运行路径不对、类不在classpath中 | 检查类名大小写,切换到class文件所在目录再运行,必要时用java -cp . 类名 |
| 错误: 在类X中找不到main方法, 请将main方法定义为... | main方法签名不对 | 确认是public static void main(String[] args),不要少public或static或[] |
| 编译器未包含main类型 | IDE中直接运行了不含main的类 | 在包含main方法的类上运行,或给当前类补一个入口 |
| Could not initialize class xxx | 类的静态初始化抛异常 | 检查静态变量赋值和静态代码块中的异常 |
| public key retrieval is not allowed | 连接MySQL时使用了caching_sha2_password插件,且未允许公钥检索 | 在JDBC连接URL加上allowPublicKeyRetrieval=true&useSSL=false |
| ClassNotFoundException | 运行时缺少依赖jar包或类路径不对 | 确认依赖是否在classpath,重新构建项目 |
重点说下public key retrieval is not allowed。这个问题经常出现在MySQL 8以上版本配合旧版JDBC驱动时。MySQL 8默认的认证插件是caching_sha2_password,客户端连接时如果服务器不允许公钥检索,就会抛出这个异常。解决办法不是关闭数据库,而是在连接串里允许公钥检索:
jdbc:mysql://localhost:3306/test?allowPublicKeyRetrieval=true&useSSL=false注意useSSL=false只是为了方便本地测试,生产环境应该配置正确的SSL证书,否则有安全隐患。
4.3 IDE里提高效率的实操习惯
如果你用的是IntelliJ IDEA或者Eclipse,直接点运行按钮之前,先检查右上角的运行配置。常见的问题是:编辑了文件名但老的运行配置还指向不存在的类,导致运行时报找不到主类。我的习惯是每次重命名类之后,直接把运行配置删掉重新Run,省去很多莫名其妙的报错。
另外,IDE通常可以自动编译,但命令行下的javac编译能让你更清楚Java程序的编译过程。我建议新手至少手动跑一遍javac和java命令,理解 classpath 和字节码的关系。特别是遇到NoClassDefFoundError和ClassNotFoundException时,你能从命令行角度快速定位:到底是类不存在,还是依赖的jar没被找到。
还有个小技巧:写main方法时,可以用IDE的psvm快捷键生成完整签名,少手敲一个关键字。但面试时手写代码就没有这个快捷键了,所以基础功还得扎实。
5. 面试高频点:从这段代码延伸出去
5.1 为什么main方法必须是public static void?
面试时,一个问题往往会连环追问。public保证JVM能访问,static保证无需实例化就能调用,void表示程序结束不需要返回值。如果你只背了结论,追问一句“为什么不能是private static void”就露馅了。private的话JVM无法访问,运行直接报错。static如果去掉,JVM得先创建类的实例,但创建实例之前JVM还没法执行任何方法,这就是循环依赖。void如果改成int,JVM也不会认,因为入口签名是固定的。
这里有一个更深的细节:JVM规范规定,main方法必须接受一个String数组。为什么不接受可变参数String... args?其实可变参数在字节码层面也是数组,但JVM只会查找([Ljava/lang/String;)V这个签名描述符,String...编译后的方法签名是([Ljava/lang/String;)V,跟数组一样。所以理论上市面上那些写public static void main(String... args)的代码也能运行,但不建议这么干,因为规范写法就是String[] args。
5.2 class<?>与反射机制
前面提到过Class<?>,这里展开说一说。Java里的每个类都对应一个Class对象,它描述这个类的结构信息。获取Class对象有三种方式:
Class<String> c1 = String.class; Class<?> c2 = "hello".getClass(); Class<?> c3 = Class.forName("java.lang.String");Class<?>中的?是泛型通配符,表示“未知类型”。为什么要用通配符?因为你在写通用代码时,可能并不知道运行时具体是哪个类。比如写一个工具方法:
public static void printClassName(Class<?> clazz) { System.out.println(clazz.getName()); }这里如果写成Class<String>,那方法只能接收String的Class,失去了通用性;直接写Class又会有泛型警告和不安全转换的问题。用Class<?>是安全和通用之间的平衡。
反射正是在Class对象基础上操作类结构的。通过clazz.getDeclaredMethods()、clazz.getDeclaredFields()等,可以获取类的方法和字段,再通过setAccessible(true)绕开访问控制。这块内容相对进阶,但面试中“手写一个简单反射获取私有字段值”是高频题。
5.3 面向对象基础:封装、继承、多态
既然标题是public class,那类与对象本身就是绕不开的话题。类是一组数据和操作数据的逻辑的模板,对象是这个模板创建出来的具体实例。比如:
public class Article { private String title; public Article(String title) { this.title = title; } public String getTitle() { return title; } }这里Article类封装了标题字段和获取标题的方法。字段私有,外部只能通过getTitle()读取,这就叫封装。继承是子类复用父类的结构和行为,多态是父类引用指向子类对象时调用同一个方法表现出的不同行为。三者是面向对象的核心,Java的public class只是承载它们的容器。
很多初学者喜欢把一切字段都public,觉得省事。但项目大了之后,你会发现自己写着写着就无法控制状态变化。比如一个订单金额字段,谁都能改,数值变成了负数都没人发现。做好封装,对外提供受限的方法,是Java工程化最基本的一课。
5.4 Java怎么保证数据一致性
这个热词也挺有意思,属于并发方向的经典问题。Java里保证数据一致性主要靠几个手段:synchronized关键字、volatile关键字、Lock接口以及原子类。
synchronized可以修饰代码块或方法,保证同一时间只有一个线程进入临界区。volatile则保证变量的可见性,防止线程缓存导致读到过期值。比如:
public class Counter { private volatile int count; public synchronized void increment() { count++; } }volatile解决的是可见性问题,但不能解决复合操作的原子性,所以这里increment还需要synchronized保证count++不会被多个线程交叉执行。更现代的方式是使用AtomicInteger:
private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); }这个知识点属于并发进阶,面试常问“volatile和synchronized的区别”“AtomicInteger怎么实现原子性”。我建议基础阶段先把线程创建和同步的知识摸清楚,再往深了挖。
6. 几条从我经验里踩出来的建议
最后分享点实操体会,不整那些虚的。我见过很多学习者把大量时间花在背语法上,结果打开IDE连一个最简单的类怎么运行都折腾半天。Java学习最有效的方式就是“抄代码—改代码—跑代码”三连。比如这个ArticleTitlePrinter,你可以试着把它改成:如果命令行传入了多个参数,用逗号分隔;或者实现一个根据标题长度画分隔线的功能。改着改着,String的常用方法和数组遍历就都熟了。
还有一个非常容易犯的错误是手动设置classpath。命令行运行时最稳妥的写法是java -cp . ArticleTitlePrinter,把当前目录明确加进classpath。不管你在哪个目录执行,都能避免“找不到或无法加载主类”。如果依赖了第三方jar,就把jar路径加到classpath里,或者直接用构建工具管理。
我个人带新人的时候,总会让他们先回答一句话:public class里的public能不能去掉?去掉之后代码还能不能运行?如果答不出来,就说明访问控制这一块还没吃透。这个问题的答案其实很简单:去掉public后类变成包可见,如果main所在的类自己作为运行入口,而且不需要被其他包引用,照样能运行。但大多数程序都会和其他包有依赖,所以入口类保持public是习惯,也是规范。
回到一开始那个被截断的代码,你会发现真正重要的不只是把它补全,而是把每个关键字背后的“为什么”讲清楚。基础这东西,越是看起来简单,越值得反复咀嚼。等你哪一天能随口说出“main必须是public static void,因为JVM需要跨包访问、无实例调用、忽略返回值”的时候,Java的大门才算真正向你敞开。