Java包与导包机制,及Scanner三种输入源从入门到实战
很多人学 Java 学到能写一点简单逻辑之后紧接着就会撞上一面墙类一多就不知道怎么组织想读用户输入又搞不清 Scanner 到底是哪来的更不理解为什么每次都要写System.in这一长串东西。标题里这三个关键词——包和导包、Scanner类、输入源——其实是 Java 入门阶段最容易被看得懂但用不明白的一组概念。这篇文章不打算把官方文档复述一遍而是从实际写代码的顺序出发把这根线彻底捋清楚读完你就能自己搭一个带包结构的项目并且让程序从键盘、文件、字符串三种输入源里稳稳当当把数据读出来。适合的人群很明确已经会写变量、循环、数组但还没搞懂多类项目怎么组织的初学者以及能跑通代码、但一遇到nextLine吞行、中文乱码、文件读不出来就抓瞎的半入门选手。下面所有代码我都用实际的踩坑经历串起来不是纸上谈兵。1. 为什么需要包类一多世界就乱了1.1 没有包的时候撞名是必然的先看一个最常见的翻车场景。你所在的小组有两个人A同学负责写登录模块B同学负责写用户管理模块。俩人各自建了一个User.java字段还都差不多。等到项目合并那天一复制文件进同一个目录IDE 直接报错重复的类定义。这不是虚构是新手项目里几乎每届都会发生的事。更麻烦的是就算不撞类名项目里几十个类全部平铺在一个文件夹里想找某个业务相关的类全凭肉眼和记忆时间一长维护成本肉眼可见地涨。类名冲突 目录混乱这两个问题就是包package要解决的。1.2 包 文件夹 命名空间 访问边界包的物理存在形式就是文件夹它的逻辑作用则是给类名加了一层命名空间前缀。package com.example.demo.model; public class User { private String name; }这行package声明必须是源文件的第一行代码注释不算。它规定了User这个类的全名是com.example.demo.model.User。世界上可以有无数个User但只要全名不同编译器就不认为它们冲突这就是命名空间的意义。物理上对应的目录结构是这样的src/ └── com/ └── example/ └── demo/ └── model/ └── User.java也就是说package com.example.demo.model和User.java 必须放在com/example/demo/model目录下是一一对应的。如果你用 IDE它会自动帮你建目录如果坚持用命令行编译记住这个命令javac -d . User.java-d .的意思就是让编译器根据 package 声明自动生成目录结构而不是把.class文件乱扔在当前目录。包命名规范也值得一提全小写通常用公司或组织的反向域名开头比如com.某公司.项目名.模块名。纯粹自己练手可以用com.example.demo这种形式千万别起com.我的代码之类的名字中文目录在跨平台编译时容易出幺蛾子。1.3 把类直接放在默认包里是最容易埋雷的习惯教程里为了演示方便常常写一个没有package的类这种类被放在默认包里。平时跟着敲没问题但一旦进入真实项目默认包就是个巨坑带包名的类在部分环境下无法正常引用默认包里的类反过来默认包的类想引用带包名的类也非常别扭。我见过有同学把一个完整的登录项目全写在默认包里最后引入第三方库时死活编译不过排查了半天才发现是包结构的问题。所以从我这里的建议很简单从第一个正经项目开始哪怕只有两个类也先建好包。习惯这玩意儿越早养成越省事后面谈导包就顺理成章了。2. 导包的三种姿势从全限定名到静态导入2.1 全限定名最原始、最啰嗦的点名方式既然类有了全名那最粗暴的使用方式当然是直接写全名public class Main { public static void main(String[] args) { java.util.Scanner sc new java.util.Scanner(System.in); } }这段代码完全合法不需要任何 import 语句。但问题也明显——每次用Scanner都得敲java.util.Scanner类多了代码可读性会变得很差。全限定名适合偶尔应急的场景日常写代码没人这么干。2.2 import给类名加个快捷方式既然全名太长Java 就用import让类名可以缩写成简单名。package com.example.demo; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); } }import语句放在package声明之后、类声明之前。它做的事情可以类比成通讯录里的快捷拨号你提前把java.util.Scanner这个全名存进去了之后每次直接叫Scanner就行。再来看通配符导入import java.util.*;这行会把java.util包下的所有顶层类都导入。注意是顶层类不包含子包里的类。很多人以为import java.util.*连java.util.regex、java.util.stream里的类也一起导入了这是个很常见的误区。*不递归子包必须单独导因为java.util.regex.Pattern的全名前缀是java.util.regex人家属于另一个包层次。关于通配符还有一个认知盲区要纠正import java.util.*并不会在运行期把java.util里几百个类全部加载进内存。import是纯粹编译期概念它只是告诉编译器遇到简单名的时候去哪些包里找。编译完成后该用的类才在真正用到的时候被类加载器按需加载。所以别担心通配符导入影响性能真正影响可读性倒是真的——别人看代码时不知道你具体用了哪个类一般推荐 IDE 里用单个类导入自动整理。另外两点java.lang包下的类String、System、Math这些是编译器自动导入的不需要手动 import和Scanner在同一个包下的类互相使用也不需要 import因为本包天然可见。2.3 静态导入把一个类的静态成员拉平使用静态导入稍微进阶一点处理的不是类名而是静态成员。比如你写数学运算时不想每次敲Math.PI可以这么干import static java.lang.Math.PI; import static java.lang.Math.abs; public class Main { public static void main(String[] args) { double r 2.5; double s PI * r * r; int distance abs(-10); } }静态导入的本意是让频繁使用的静态常量、静态方法少点前缀噪音。但它有个副作用会污染当前文件的命名空间一旦多个类都有同名静态成员冲突排查非常难受。我的建议是只用于常量或者语义极度清晰的工具方法import static不到迫不得已别滥用尤其别import static java.util.*这种把静态成员全部拉平的写法那基本是给自己挖坑。3. Scanner 的构造器矩阵输入源决定它的活法3.1 System.in 到底是什么要搞懂 Scanner先得明白System.in是什么。它是一个InputStream类型的对象代表标准输入流默认情况下连接着键盘。你可以把它理解成一个字节水龙头你按键产生的字节就是流里的水。而Scanner是一个净水器它把水龙头流出来的原始字节解析成整数、小数、字符串等更干净的数据类型。3.2 构造器一览Scanner 不只为键盘而生很多人以为 Scanner 就是用来读键盘输入的这是个巨大的误解。看它有哪些构造器就知道它是个换源高手构造器输入源类型典型场景Scanner(InputStream)字节流如System.in读键盘输入Scanner(File)文件对象直接读文件内容Scanner(Path)路径对象Java 7 之后读文件的另一种写法Scanner(String)字符串解析一段内存里的文本数据Scanner(Readable)任意可读字符流接BufferedReader、StringReader等Scanner(ReadableByteChannel)字节通道相对少见NIO 场景这里最需要建立的概念是Scanner 的核心价值是把字符数据解析成结构化数据至于数据从哪来它并不关心。键盘可以、文件可以、一个普通字符串也可以。3.3 键盘、文件、字符串三种源的手感对比键盘输入的代码你肯定见过Scanner sc new Scanner(System.in);关键在于等你学会了文件输入源之后代码几乎长得一样Scanner sc new Scanner(new File(data.txt));而解析字符串源则是这个画风Scanner sc new Scanner(张三,25,工程师);同一个解析器换一个构造参数输入源就变了。这种数据源与数据处理逻辑解耦的设计才是题目里输入源这个词真正要传达的东西。日常刷题、写小工具时先用字符串源验证解析逻辑跑通了再换成文件源debug 效率会高很多。这也是我自己测试解析代码常用的套路先把要解析的内容贴成字符串调试不确定的逻辑全搞定之后再切换到真实文件避免一边抓解析 bug 一边抓 IO 异常两头乱。4. 核心方法速览next、nextLine 与 hasNext 的配合4.1 按块读nextInt、nextDouble 与 nextScanner 默认把输入按空白符空格、制表符、换行切成一个个tokennextInt()就是从这些 token 里解析一个整数Scanner sc new Scanner(System.in); System.out.print(请输入年龄); int age sc.nextInt(); System.out.println(你的年龄是 age);如果输入18方法直接返回整数 18如果输入abc运行时会抛出InputMismatchException。nextDouble()、nextLong()同理都是取下一个 token 并尝试转成对应类型。4.2 按行读nextLinenextLine()的逻辑跟上面不一样它不管空白符切分而是从当前位置一直读到换行符为止把这一整行作为字符串返回。注意它的语义是消费掉一行数据包含末尾的换行符但返回值不含换行符。这两种方法混着用的时候新手最容易踩坑下一节我会专门演示完整的翻车链路这里先记住一个关键差别nextInt()这类方法读到的是纯净的 token它不会消费掉 token 后面的换行符而nextLine()则是行级消费你调用它的时候缓冲区里就算只剩一个换行符它也会扫完这一行然后返回一个空字符串。4.3 探测式读取hasNext 家族在不知道数据有多少个的情况下hasNextInt()、hasNextDouble()、hasNextLine()这些探测方法就派上用场了。它们不会真正消费数据只是看看下一个 token 能不能解析成目标类型能不能继续读Scanner sc new Scanner(1 2 3 4 5); int sum 0; while (sc.hasNextInt()) { sum sc.nextInt(); } System.out.println(和是 sum);这段代码会依次读出 1 到 5然后hasNextInt()发现没有更多整数了循环退出最终输出 15。用来从文件里读每行一个数字之类的数据这个模式几乎是万能模板——先hasNextXxx判断再对应的nextXxx取值循环体干干净净。4.4 自定义分隔符useDelimiter默认分隔符是空白但实际数据不总是用空格隔开的。比如解析一行 CSV 风格的文本张三,25,工程师可以这样Scanner sc new Scanner(张三,25,工程师); sc.useDelimiter(,); while (sc.hasNext()) { System.out.println(sc.next()); }输出张三 25 工程师useDelimiter接受字符串或正则表达式注意如果文本里本身有换行你需要把\r?\n也写进分隔符表达式否则会出现分割不均的问题。我实际用下来CSV 解析用String.split(,)更直观但涉及复杂多分隔符、又要流式处理大文本时useDelimiter仍然是 Scanner 家族里最能打的那把刀。5. 实测踩坑nextLine 吞行、编码与资源关闭5.1 吞行事件的完整根因链条先看这段几乎每届初学者都会写的代码Scanner sc new Scanner(System.in); System.out.print(输入岁数); int age sc.nextInt(); System.out.print(输入姓名); String name sc.nextLine(); System.out.println(name);运行起来你会发现年龄输入完成回车之后程序根本没有让你输入姓名直接把空字符串或者老数据打出来了。问题出在缓冲区的内容状态。假设你在键盘上敲了20然后回车。nextInt()读到的是整数 20但紧跟它后面的那个换行符\n还留在缓冲区里。紧接着nextLine()开始工作它发现缓冲区开头就是一个换行符于是消费掉这一行直接返回结果你拿到的是一行空内容。这就是nextLine 吞掉了输入的真面目——它吞的其实是你上一次输入时遗留下来的换行符。解决办法有两个思路我建议按场景选思路一用nextLine()消费掉换行int age sc.nextInt(); sc.nextLine(); // 把残留的换行符消费掉 String name sc.nextLine();思路二干脆全部按行读再用Integer.parseInt转换int age Integer.parseInt(sc.nextLine()); String name sc.nextLine();第二种写法能彻底绕开混用问题但注意如果输入不是纯数字Integer.parseInt会抛NumberFormatException适合能接受异常退出的小工具。真实项目我更推荐按行读再转换因为一行一读的边界最清晰最能避免缓冲区残留问题的连锁反应。5.2 资源关闭System.in 到底该不该 closeScanner 实现了AutoCloseable但关闭 Scanner 会关闭它背后的输入源。自己 new 的Scanner(File)或Scanner(new StringReader(...))用完用 try-with-resources 关闭是绝对正确的扫描System.in的时候我见过有人顺手把sc.close()一写结果程序后续其他地方再想用系统输入就彻底读不到了。因为关闭之后System.in这个全局标准输入流也被关掉了这不是什么优雅退出而是让程序失去了标准输入能力。所以我的习惯是只有文件、网络流这类外部资源才使用 try-with-resources 自动关闭键盘输入的 Scanner 不主动 close让它随着进程结束自然回收。道理很简单System.in不是你创建的你也没有权利把它随意关闭。5.3 中文乱码不指定编码的 File 构造器是元凶new Scanner(new File(data.txt))看起来清爽但实际读中文文件非常容易乱码。原因是这个构造器使用的默认字符集依赖运行平台不同操作系统甚至同一个系统不同地区设置下默认编码都不一样。想稳定读到 UTF-8 文件要换成显式指定编码的写法Scanner sc new Scanner(new FileInputStream(data.txt), StandardCharsets.UTF_8);new FileInputStream(...)先得到一个字节流再通过Scanner(InputStream, Charset)构造器传入字符集。这里用FileInputStream而不是File恰恰就是因为带字符集的构造器只接收输入流而不接收 File 对象。我自己在 Windows 上写文件解析就吃过这个亏后来统一规定凡是涉及中文的业务数据文件一律显式写 UTF-8绝不依赖环境默认值。5.4 性能真相Scanner 不是万能的慢网上常说 Scanner 慢、要用 BufferedReader 替代这话对了一半。Scanner 慢是因为它内部用了正则表达式来切分 token你每调一次next()都要过一次正则引擎。但这个损耗主要出现在需要高频、大量、持续读取的场景比如逐行处理百万行日志。如果只是交互式读取用户的几个输入或者解析几百行的配置文件Scanner 的便利性完胜 BufferedReader 的繁琐。真要追求性能又不愿失去便利折中方案是用BufferedReader.readLine()按行读出字符串再用Scanner或String.split解析每行内容。我实测过这个组合在大文件场景下性能接近纯 BufferedReader但代码可读性比纯手写解析好很多。6. 一个综合小实战包结构 文件输入源 数据解析6.1 先搭一个正经的包结构把这一整套知识串起来最有效的方式是先建一个有包名的项目骨架。比如src/ └── com/ └── example/ └── score/ ├── Main.java └── util/ └── FileNumberReader.java这里Main.java负责运行util子包下的FileNumberReader负责从文件里读数字。分层的意义是以后你想把 Reader 换成网络源或数据库源Main里的调用逻辑几乎不用动。6.2 从 UTF-8 文件读取整数并求和假设有个scores.txt内容是每行一个整数。我们用显式 UTF-8 的方式读它package com.example.score.util; import java.io.FileInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Scanner; public class FileNumberReader { public static int sumFromFile(String path) throws IOException { int sum 0; try (Scanner sc new Scanner( new FileInputStream(path), StandardCharsets.UTF_8)) { while (sc.hasNextInt()) { sum sc.nextInt(); } } return sum; } }注意hasNextInt()在这里是智能跳过非整数 token 吗不是的——如果文件里混入了非整数字段它只会跳过那些位置不消费整数的求和结果依然正确但非整数内容被静默忽略。如果你希望解析失败就报错那就得换next()加分号逻辑或者在循环里手动判断。这个行为差异我建议你根据业务决定别想当然。Main.java里调用package com.example.score; import com.example.score.util.FileNumberReader; public class Main { public static void main(String[] args) throws Exception { int sum FileNumberReader.sumFromFile(scores.txt); System.out.println(总分 sum); } }注意Main所在的包和FileNumberReader不同所以必须写import com.example.score.util.FileNumberReader;。如果省略这一行编译器会告诉你找不到符号这就是导包在真实项目里最直接的存在感。6.3 从字符串解析一组 KV 配置文件源会了字符串源也顺手练一下。假设有一串配置字符串timeout30 retry3 verbosetrue想用 Scanner 拆开Scanner sc new Scanner(timeout30 retry3 verbosetrue); while (sc.hasNext()) { String token sc.next(); String[] kv token.split(); System.out.println(键 kv[0] 值 kv[1]); }这段代码不需要任何文件 IO一次字符串构造就能把解析逻辑验证完。日常写配置解析、刷算法题读内存格式这个模式省心得很。6.4 键盘循环读取让它自己决定什么时候停最后补一个键盘源的循环读法。用户输入exit结束程序Scanner sc new Scanner(System.in); while (true) { System.out.print(请输入指令exit 退出); String line sc.nextLine(); if (exit.equals(line)) { break; } System.out.println(收到 line); }这里用sc.nextLine()而不是sc.next()是因为命令行交互天然是用户敲一行、程序回一行的节奏按行读最符合直觉。exit.equals(line)的写法把常量放在前面就算line为 null 也不会抛空指针这是我在真实项目里保下来的习惯。经过这一套从包结构到三种输入源的完整走读你会发现标题里那三个关键词其实是同一条学习链上的三环包解决类怎么组织导包解决类怎么引用Scanner 与输入源解决数据怎么进来。以后写 Java 项目遇到类找不到第一时间检查包路径和 import遇到读不到数据第一时间检查输入源构造参数和编码遇到读出来不对第一时间检查 next 系与 nextLine 的边界。这套排查顺序比死记语法省力太多。