
Java课程走到Day17基本语法、面向对象、集合框架这些大块头都啃得差不多了IO流也见过了这时候突然冒出一个Properties类很多人第一反应是又一个Map变种。但真等你进了项目才会发现这个不起眼的类几乎无处不在数据库连接参数要读properties日志开关要读properties消息推送的开关还是要读properties。配置和代码分离这是所有Java应用从玩具走向工程的第一个门槛。这篇文章不是简单复述教程我是把Day17这个专题拆开揉碎用我实际写代码过程中踩过的坑、用过的套路来讲清楚三件事Properties类本身怎么用、使用过程中一定会遇到的编码和路径问题怎么破、以及它和Spring Boot那套配置体系到底是什么关系。不管是刚学完IO准备做课设的同学还是工作后需要快速排查配置加载问题的开发这篇都值得花十分钟细看。1. Day17的定位Properties为什么偏偏在这一天登场1.1 课程编排背后的逻辑如果你翻过完整的Java课程目录会发现Properties类通常被放在IO流之后、多线程之前。这个位置很有讲究。Properties的本质是一个能把文件内容读成键值对也能把键值对写成文件的集合类它同时依赖两个你刚学完的东西Map体系里的Hashtable以及IO体系里的输入输出流。课程这么排其实是让你把一个类当成前面知识的综合练习。另一个角度是从应用场景看。Day17之前你写的程序数据库地址、用户名、密码基本都是硬编码在代码里的改一次配置就要改源码、重新编译、重新打包。但Day17学的Properties让我第一次体验到配置文件最直接的感受是改东西不用碰代码了。这种配置外置的思维是后面学习Spring、Spring Boot的一颗种子。1.2 Properties到底是个什么东西java.util.Properties位于java.util包下继承自HashtableObject,Object。注意这个继承关系它决定了两个很重要的点。第一因为Hashtable是线程安全的所以Properties对象在多线程环境下做简单的get和set不至于出大乱子这在早期的Java版本里算是个卖点。第二它本质上就是个键值对的集合key和value默认都是String类型虽然它底层允许Object但正常使用你不会想往里塞别的类型。它的核心能力有两个方向。一个方向是读也就是把.properties文件里的内容加载成程序里的键值对对应的方法是load另一个方向是写把程序里的键值对保存到文件里对应的方法是store。整节课的所有操作基本都是围绕这两个方向展开的。1.3 一个文件格式的约定用Properties之前先得认识它要读的文件长什么样。.properties文件有一套自己的规范字符串风格几行就能说清# 这是注释用井号开头 ! 也可以用英文感叹号开头写注释 jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.url:jdbc:mysql://localhost:3306/mall jdbc.username root三种分隔符都能用等号、冒号、空格。个人建议统一用等号因为空格在配置值里还容易引发歧义。一行一个键值对键和值中间的等号两侧空格会被忽略但值内部、两端的空格处理规则和你想的可能不完全一样值末尾的空格会被忽略想保留必须用转义。键值对后面不能有行内注释否则连注释一起会变成值的一部分。多行值需要用反斜杠续行但这些属于偏门用法先把常规格式掌握牢。2. 读配置的三条路径文件流、classpath与系统属性2.1 最直觉的第一版代码文件流方式课程上教的第一个用法基本都是拿到文件路径创建输入流加载。以项目里最常见的db.properties为例import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Properties; public class DbConfigDemo { public static void main(String[] args) { Properties props new Properties(); try (InputStream in new FileInputStream(db.properties)) { props.load(in); } catch (IOException e) { e.printStackTrace(); } String url props.getProperty(jdbc.url); String username props.getProperty(jdbc.username); String password props.getProperty(jdbc.password); System.out.println(url url); System.out.println(username username); } }这里用到了try-with-resourcesJava 7以后的标准写法省去了手动close的麻烦。load方法负责解析输入流里的内容一行行转成键值对放到Properties对象里。之后用getProperty(jdbc.url)就能拿到配置值。这段代码在IDE里一跑通常能成。但这里面藏着一个坑我当时就栽过。2.2 路径问题的第一个坑相对路径到底相对谁new FileInputStream(db.properties)用的相对路径对不同的人来说当前目录的含义完全不同。你在IDEA里点运行时工作目录是模块根目录文件放对位置就能读到但如果你在命令行里用java -cp target/classes com.example.DbConfigDemo跑当前目录又成了你敲命令的那个目录。更麻烦的是代码最终打成jar包用java -jar app.jar运行工作目录再次变化。这也就是为什么真实的Java项目几乎不用文件路径去读配置文件。配置文件的归属地和代码是绑定的代码在哪配置就在哪更方便的做法是走classpath。2.3 用classpath让路径不再飘所谓classpath就是Java运行时查找类文件、资源文件的一组路径。你在IDE里跑单元测试target/classes是classpath你打jar包jar内部的目录结构也是classpath。把properties文件放在classpath下然后按照classpath的规则去找就不怕当前目录漂移了。Properties props new Properties(); try (InputStream in DbConfigDemo.class.getResourceAsStream(/db.properties)) { props.load(in); }getResourceAsStream是Class类提供的方法以斜杠开头表示从classpath根目录找斜杠加文件名/db.properties就能稳定地读到所有classpath环境中都能找到的配置。还有一个等价的写法是InputStream in Thread.currentThread().getContextClassLoader() .getResourceAsStream(db.properties);ClassLoader.getResourceAsStream不接受斜杠开头的路径都是classpath根基准。两种方式各有适用场景最简单的记忆方法是带斜杠用Class不带斜杠用ClassLoader它们俩都从classpath里找资源。2.4 系统属性另一种Properties的读法严格来说这条路不算是配置文件但它用的还是Properties这套体系。System.getProperties()返回的本身就是Properties对象存的是JVM的系统属性java.version17.0.10 java.home/Library/Java/JavaVirtualMachines/... user.dir/Users/me/my-project file.encodingUTF-8在代码里可以直接取String fileEncoding System.getProperty(file.encoding); System.out.println(fileEncoding);更关键的是你可以通过启动参数向JVM注入自定义属性。运行jar时的写法是java -Dapp.envprod -jar app.jar-Dapp.envprod这段会在JVM启动时把app.env这个键值对塞进系统属性。代码中System.getProperty(app.env)就能拿到。注意-D参数要放在-jar之前放在后面会被当成程序参数传给main方法这个顺序问题让不少人踩过坑。系统属性和配置文件结合使用也是Spring Boot环境变量抽象的思想源头。3. 汉字乱码的根源ISO-8859-1与Reader救场3.1 乱码是怎么发生的Properties的规范文档里有一句话load(InputStream)读取的字节流按ISO-8859-1编码解析这不是bug是设计。ISO-8859-1也叫Latin-1单字节编码一个字节对应一个字符。你用UTF-8保存properties文件里面写着username张三load时每个汉字被拆成两到三个字节每个字节被单独翻译成Latin-1字符于是张三就变成了å¼ ä¸‰这类天书。我在课程里第一次跑出这种输出时第一反应是怀疑操作系统、怀疑IDE最后翻源码才发现是Properties的编码假设太古老。它出生在Java 1.0年代那会儿网络传输和文件存储的主流编码就是ISO-8859-1后来文件系统转UTF-8了Properties的默认行为却没跟着变。3.2 正确解法用Reader指定编码Properties除了load(InputStream)还有一个重载load(Reader)。Reader本身就是字符流它可以带着明确的字符编码去读字节所以解决办法是Properties props new Properties(); try (InputStream in DbConfigDemo.class.getResourceAsStream(/db.properties); InputStreamReader reader new InputStreamReader(in, StandardCharsets.UTF_8)) { props.load(reader); }InputStreamReader负责把字节按UTF-8解码成字符load(Reader)再把字符流解析成键值对。这样之后无论properties文件里写中文还是其他语言都能正确显示。load(Reader)是Java 6加入的但在网上搜properties中文乱码时常见的老回答还都在推荐native2ascii转义工具那属于很老的解决方案现在写新代码直接用Reader就能干净利落地甩掉这个问题。3.3 什么时候不能这样干Reader方案虽好有一个前提你得确切知道properties文件用什么编码保存的。如果文件本身是GBK你写StandardCharsets.UTF_8反而会把GBK读乱。团队协作时要约定配置文件一律UTF-8无BOMIDEA里可以在Settings - Editor - File Encodings里对.properties文件做统一设置。还有一类场景需要保留InputStream方式就是把properties文件从XML里读出来的loadFromXML(InputStream)那是另一套编码逻辑。常规读.properties文件能走Reader就走Reader这是最省心的路线。3.4 store写回时的中文问题读文件的乱码解决了写文件还会遇到一样的问题。store(OutputStream, String)同样按ISO-8859-1输出你把中文配置写进去要么变成乱码要么被转成\uXXXX形式的Unicode转义序列。读取时虽然能还原但文件没法直接看。解决办法和读方向对称用store(Writer, String)try (FileOutputStream out new FileOutputStream(app.properties); OutputStreamWriter writer new OutputStreamWriter(out, StandardCharsets.UTF_8)) { props.store(writer, 系统配置); }不过要留意注释参数系统配置里的中文字符在某些JDK版本和写法的组合下会出现编码问题保险做法是注释保持英文。4. 把配置写回去setProperty与store的完整姿势4.1 为什么要写配置文件有些课堂练习只讲读配置不讲怎么写但真实项目里写配置是常态。典型场景桌面应用让用户自定义主题色参数要存下来下次启动读取批处理任务要记录上次执行的时间程序中断后能从断点恢复运维脚本需要动态调整某个开关而不去改代码。凡此种种都可以用Properties的写方向完成。往Properties对象里塞新键值对的方法是props.setProperty(theme.color, #409EFF); String oldValue props.setProperty(theme.color, #FF0000); System.out.println(旧值 oldValue); // 输出 #409EFFsetProperty返回的是旧值这一点继承自Map.set方法。你可以拿这个返回值判断某个键之前是否存在、值是什么。在代码逻辑上这比先getProperty再containsKey判断来得简洁。4.2 store方法与注释参数存储的代码骨架是try (OutputStream out new FileOutputStream(app.properties)) { props.store(out, 更新时间2025-06-01); } catch (IOException e) { e.printStackTrace(); }store接收两个参数第二个参数是文件头注释会在properties文件最前面以#注释的形式写出来方便后来者快速了解文件用途。如果不需要注释传null即可。要注意的问题是store写文件时的顺序不受控制。Properties继承HashtableHashtable是个无序结构存储多次set进去的键输出到文件的顺序不保证和插入顺序一致。如果你希望配置项在文件里保持稳定的、可读的顺序有两个办法。一个是改用properties的文件形式绕过去绕不过去只能替换集合类型。另一个更常见的思路直接不用Properties对象来存储顺序而是自己维护一个LinkedHashMap再写个方法逐行输出属于自定义配置管理方案。不过说实话大多数项目对配置文件顺序并没有洁癖能正常读取、人能找到就行。4.3 写文件的编码与位置注意写文件同样逃不开编码。上文提过用OutputStreamWriter包装一层指定UTF-8中文值就不会变成\u转义。还有一点写文件前先明确路径写到哪里别用相对路径指望它一定落在项目根目录。稳妥做法是把配置文件路径设计成从配置里读或者从用户目录里拼一个固定路径比如System.getProperty(user.home) /myapp/app.properties。5. 热加载、工具封装与框架里的Properties5.1 Properties不会自动刷新一个设计警告Properties加载配置文件后文件的内容就变成内存里的一份快照。外部改了文件程序里读到的永远是老值。这在开发环境里经常骗到人——改了配置文件程序输出没变化七查八查才发现是没重启或者没重新加载。课程层面讲清楚这一点就够了但如果你做项目前想多走一步可以自己设计一个轻量级热加载。思路很简单加载时记录文件的最后修改时间lastModified启动一个定时任务每隔几秒钟检查一次发现文件修改了就重新load。核心骨架长这样public final class HotConfig { private static volatile Properties props new Properties(); private static volatile long lastLoadedTime 0L; private static final Path CONFIG_PATH Paths.get(conf, app.properties); static { reload(); } public static void reload() { try (Reader reader Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { Properties newProps new Properties(); newProps.load(reader); props newProps; lastLoadedTime Files.getLastModifiedTime(CONFIG_PATH).toMillis(); } catch (IOException e) { throw new RuntimeException(配置文件加载失败, e); } } public static void checkAndReload() { try { long current Files.getLastModifiedTime(CONFIG_PATH).toMillis(); if (current ! lastLoadedTime) { reload(); } } catch (IOException ignored) { } } public static String get(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }把props声明为volatile是防止多线程读的时候拿到半个加载状态重新加载时先new新对象再整体覆盖引用避免清空再填充带来的短暂空窗期。这就是最简单版本的配置中心客户端原理解析再往上走就是Nacos、Apollo那些工业级方案的活。5.2 封装一个全局配置工具类既然Properties经常在多个类里被用到与其每个类都new一个Properties再load一次文件不如封装成静态工具全工程共享一份。上面HotConfig类的骨架实际上就是一个好的范式。需要注意的点静态代码块做首次加载保证任何类使用配置前文件已经加载好get方法提供带默认值的重载配置缺失时不要随便返回null加载失败时抛异常要比静默吞掉好否则配置错了程序带病运行排查问题非常折磨不过工具类封装也容易走到另一个极端加了大量锁、复杂刷新逻辑、多个配置源最后反而比直接用框架难维护。项目里如果已经用了Spring Boot直接交给框架的机制去管理就行自己做的简单工具类更适合无框架的小程序或学习项目。5.3 从Properties到Spring Boot的配置体系很多人在框架项目里写着写着就忘了Properties。比如Spring Boot里的application.properties本质上还是keyvalue的格式只是加载、解析、注入这层活被框架接管了。server.port8080 spring.datasource.urljdbc:mysql://localhost:3306/mall spring.datasource.usernameroot spring.datasource.password123456Spring Boot启动时会把一堆PropertySource分别加载application.properties只是其中之一环境变量、命令行参数、jar包外的配置文件都在同一套优先级规则下参与竞争。表面上看这段机制比裸的java.util.Properties复杂太多但底层思路并不神秘把配置项转成键值对再根据key找到对应的注入位置。理解了Properties再去看Value(${server.port})和ConfigurationProperties(prefix spring.datasource)就能感觉到它们是同一件事的两层外衣。5.4 配置文件加载顺序环境隔离的基础多环境部署是配置管理的另一个必修话题。开发、测试、生产数据库地址、日志级别、调用第三方服务的地址都不一样怎么办最简单的方案是准备dev.properties和prod.properties运行时通过命令行参数指定加载哪个比如java -Dapp.envprod -jar app.jar代码里可以用System.getProperty(app.env)拿到环境标识再拼出对应的配置文件路径。Spring Boot把这种方式做成了更优雅的profile机制但本质依然是根据启动参数决定加载哪一份配置。你只要在Day17阶段自己动手写过一次这种多环境加载后面理解Spring profile会轻松很多。6. 面试考点与高频误操作盘点6.1 面试最爱问的几个点Properties这块在面试中不算大头但属于基础题里的小考点常见的问题集中在这几个位置第一load(InputStream)和load(Reader)的区别。回答要从编码切入前者按ISO-8859-1解析字节流适合纯ASCII内容后者可以指定编码适合包含中文的配置文件。JDK 9以后load(Reader)对UTF-8有了更好的支持但推荐主动指定字符集而不是依赖默认值。第二getProperty和get的区别。getProperty接收String的key返回值也是String同时提供带默认值的重载get是从Map继承过来的返回Object需要强转。要避免直接用get(key)然后拿Object去调字符串方法编译期IDE会直接报错。第三Properties和Hashtable的继承关系。有人会问Properties线程安全吗答案是它的底层方法通过Hashtable加锁单个方法的原子性没问题但多个方法组合的业务逻辑并不保证原子。这个层次感说出来面试官基本就满意了。第四properties文件和xml文件的配置方案取舍。Java原生的配置除了properties还有XMLSpring早期大量用XML配置文件。Properties适合简单扁平键值对XML适合有层级、有结构的复杂配置。后来YAML在Spring Boot里流行也是因为它在层级表达上比properties优雅同时比XML简介。6.2 高频报错与对应解法整理一下我这几年帮人看代码时见过最多的Properties相关报错和现象现象根因处理建议FileNotFoundException文件路径不存在或相对路径基准不对改用classpath方式或打印user.dir确认工作目录中文乱码load(InputStream)按ISO-8859-1解析改用InputStreamReaderUTF-8配置读到nullkey拼写错误或文件没加载成功用getProperty带默认值并打印加载结果排查修改配置不生效Properties不会自动刷新重启或实现热加载逻辑Spring Boot读不到自定义配置配置文件位置或命名不对检查classpath根下application.properties注意编码有个现象值得单独提一下。现在搜Properties报错能搜到一大堆cannot read properties of undefined (reading xxx)看着像配置读取失败其实是JavaScript运行时的报错跟前端框架无关跟Java的Properties更是八竿子打不着。Java侧读properties如果报异常常见的要么是FileNotFoundException要么是NullPointerException——前者是文件没找到后者是你尝试从一个没加载成功的Properties对象上取值。定位方向上别被误导。6.3 持久化之外的隐性问题还有一个不常被提到但要留心的问题Properties虽是配置文件的事实标准工具但它把键值对读出来后所有值都以String形态存在。这意味着类型转换得自己做。端口号配置读出来是字符串8080要转成int用Integer.parseInt开关配置读出来可能是true或TRUE要小心处理大小写。配置值内部如果前后有意外空格解析时容易被忽略掉调试起来很隐蔽。这种问题没有黑魔法解法只能是写个健壮的工具方法统一处理trim和默认值。7. 一些小经验总结学Properties这个类最值得记住的不是API本身而是它的两个痛点编码和路径。编码问题源于它设计于ISO-8859-1时代路径问题源于当前目录的飘忽不定。把这两件事处理好了绝大部分properties相关的坑你都绕过去了。我自己的实践习惯是两条第一所有配置文件统一UTF-8加载时一律指定字符集绝不用系统默认值第二配置文件尽量放classpath或者用绝对路径拼出来绝不在代码里写裸的相对路径。这两条看起来简单但能帮你省掉的排查时间远超想象。如果你正在按部就班学Java建议把Day17的练习稍微延伸一步做一个模拟登录的小程序把用户名密码、接口地址、重试次数都放到properties里再试着用-D参数覆盖其中一项配置。这样操作一次配置与代码分离的思想你会体感非常深刻。后面学到Spring Boot你会感谢这一天。