Java的运行流程:
Java 代码文件的运行涉及编译和执行两个主要阶段
1. 编写 Java 源代码
首先要用文本编辑器或者集成开发环境(IDE)来编写 Java 代码,代码需保存为.java文件。比如创建一个名为HelloWorld.java的文件,其内容如下:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}2. 编译 Java 源代码
借助 Java 编译器(javac)对.java文件进行处理,从而生成字节码文件(.class)。具体操作步骤如下:
- 打开命令行终端。
- 进入保存
.java文件的目录。 - 执行编译命令:
javac HelloWorld.java(用 javaac 命令运行.java文件,生成一个.class文件) - 若代码不存在错误,就会生成一个名为
HelloWorld.class的文件。
3. 执行 Java 字节码
通过 Java 虚拟机(JVM)来执行生成的字节码文件,步骤如下:
- 在命令行中,确保当前目录包含
.class文件。(进入包含.class文件的目录下) - 运行命令:
java HelloWorld(注意这里不需要写.class后缀)。 - JVM 会加载并执行
main方法,最终输出结果:Hello, World!。
运行流程示意图
编写Java代码 (.java文件) → 编译(javac) → 字节码文件(.class) → 加载到JVM → 执行关键点说明
- 文件名与类名:Java 要求公共类(被
public修饰的类)的名称必须和文件名完全一样,就连大小写也要一致。 - 字节码的平台无关性:编译生成的字节码能够在任何安装了兼容 JVM 的系统上运行,这体现了 Java“一次编写,到处运行” 的特性。
- JRE 与 JDK
- JRE(Java Runtime Environment)是运行 Java 程序必不可少的环境,它包含了 JVM 和核心类库。
- JDK(Java Development Kit)是 Java 开发工具包,其中包含了 JRE、编译器(
javac)以及其他开发工具。
- main方法:这是 Java 程序的入口点,其固定格式为:
public static void main(String[] args) { ... }常见问题及解决办法
- 编译错误:当出现
错误: 找不到符号这样的提示时,要检查类名、方法名的拼写是否正确,以及变量是否已经被声明。 - 运行错误:若提示
错误: 找不到或无法加载主类,需确认类名和文件名是否一致,.class文件是否位于当前目录,以及CLASSPATH的设置是否正确。 - 版本不兼容:如果出现
Unsupported major.minor version错误,说明编译和运行时使用的 Java 版本不一致,需要保证 JDK 和 JRE 的版本相同。
JDK的下载和安装
JDK的下载:
- 通过官方网站获取需要的
JDK版本 JDK官网:http://www.oracle.com- 注意:针对不同操作系统,下载对应的安装包
JDK的安装:
傻瓜式安装,下一步即可
建议1:安装路径中不要包含中文和空格(因为软件是国外开发,对中文的支持度不是特别高,安装路径带中文和空格,日后可能会出现报错)
建议2:所有的开发工具最好安装目录统一(便于日后管理)
JDK的安装目录:
- bin:该路径下存放了各种工具命令。其中比较重要的有:javac 和 java
- conf:该路径下存放了相关配置文件。
- include:该路径下存放了一些平台特定的头文件。
- jmods:该路径下存放了各种模块。
- legal:该路径下存放了各模块的授权文档。
- lib:该路径下存放了工具的一些补充JAR包。
JDK编译运行:
//首先打开 CMD
C:\Users\moon>F: //切换盘符,也就是切换要操作的文件位置
F:\>cd JavaSE最新版\day01\代码 //进入到需要进行操作的文件位置
F:\JavaSE最新版\day01\代码> javac HelloWorld.java //编译:使用JDK提供的javac 编译书写的java代码为.class文件(让操作系统可以看懂的文件)
F:\JavaSE最新版\day01\代码> java HelloWorld //运行:使用JDK提供的java工具来运行编译后生成的.class文件运行,不需要加 .calss后缀名
HelloWorld //运行结果
F:\JavaSE最新版\day01\代码>IDEA的下载安装
IDEA下载地址:https: //www.jetbrains.com/idea/
安装:傻瓜式安装,修改安装路径即可。
终极版的IDEA要收费使用,可以去网上找破解码输入。
破解教程网站:IDEA 2025.1.1.1 最新破解版安装教程(附激活码,至2099年~) - 犬小哈教程
注意:
若是换新版本的 IDEA 使用,需要先把旧版本的 IDEA 彻底删除
1、卸载 IDEA 程序
先去控制面板卸载
卸载时点击勾选第一栏 Delete IntelliJ IDEA 2022.2 caches and local history,表示同时删除 IDEA 本地缓存以及历史,Delete IntellJ IDEA 2022.2 settings and installed plugins : 删除 IDEA 相关设置,如字体大小、主题等等,以及删除已安装的插件
缓存设置都删除
2、注册表清理
较老版本的 IDEA 安装成功后,会在注册表中都会留下一些信息,也需要清理一下。
按住快捷键 windos + R, 然后输入 regedit 回车调出注册表。
依次点击菜单 计算机\HKEY_CURRENT_USER\SOFTWARE\JavaSoft\Prefs\jetbrains, 然后右键删除jetbrain,在计算机\HKEY_CURRENT_USER\Environment发现IntellijIDEA相关删除。注意,如果注册表中未发现下图中的信息,说明你安装的 IDEA 版本较新,可以不用清理注册表,继续执行后续的步骤:
3、残留清理
最后,还有几个地方的缓存数据需要删除:
下面两个方法任选其一即可
去文件管理器删除
打开C盘,勾选查看隐藏的项目
AppData > Local > JetBrains 目录中删除掉 IntellijldeaXXX文件夹
AppData>Roaming>JetBrains目录中删除掉IntellijldeaXXX文件夹
C:\用户\公用.jetbrains
C:\Program Files\JetBrains
*C:\ProgramData\Microsoft\Windows\Start Menu\Programs\JetBrains*
使用命令行删除
c:\用户\${用户名称}\IdeaProjects\
# 如果你想删除 IDEA 相关,则只需要删除 JetBrains 目录下包含 IDEA 的文件夹即可
C:\用户\${用户名称}\AppData\Roaming\JetBrains
# 如果你想删除 IDEA 相关,则只需要删除 JetBrains 目录下包含 IDEA 的文件夹即可
C:\用户\${用户名称}\AppData\Local\JetBrains
C:\用户\公用\.jetbrains
# 如果你想删除 IDEA 相关,则只需要删除 JetBrains目录下包含 IDEA 的文件夹即可
C:\Program Files\JetBrains
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\JetBrains\Mac系统卸载步骤
Mac 系统要想彻底卸载 IDEA, 步骤如下:
1、卸载 IDEA 程序
打开【访达】-> 【应用程序】, 找到 IDEA 右键【移到废纸篓】:
2、残留清理
打开终端,执行如下命令,进入 Library 目录下,准备删除 IDEA 的残留信息:
cd /Users/XXX/Library
注意:路径中的 XXX 为用户名,替换成你实际的用户名,再执行即可。新老版本残留所在的目录不一样
建议先 cd 进去看下文件夹命名规则,再删除。
较新版本
执行如下删除命令:
rm -rf Preferences/JetBrains/IntelliJIdea*
rm -rf Caches/JetBrains/IntelliJIdea*
rm -rf Application\ Support/JetBrains/IntelliJIdea*
rm -rf Logs/JetBrains/IntelliJIdea*
TIP: * 表示版本号老版本
执行如下删除命令:
rm -rf Preferences/IntelliJIdea*
rm -rf Caches/IntelliJIdea*
rm -rf Application\ Support/IntelliJIdea*
rm -rf Logs/IntelliJIdea*IDEA中的项目结构
一般是新建一个项目文件,一个项目里面包含多个模块,模块下面包含多个文件包,各个包里面存放着分好类(类似功能)的java文件
project(项目)
-->module 01(模块)
-->package 01(包)
-->class(java代码文件)
-->class(java代码文件)
-->class(java代码文件)
-->package 02(包)
-->class(java代码文件)
-->class(java代码文件)
-->package 03(包)
-->class(java代码文件)
-->module 02(模块)
-->package(包)
-->class(java代码文件)
-->class(java代码文件)
-->package(包)
-->class(java代码文件)IDEA中的设置
切换IDEA主题
File -----> Settings ------> Appearance & Behavior ------> Appearance -----> Theme
切换IDEA背景图
File -----> Settings ------> Appearance & Behavior ------> Appearance -----> Background Image
切换字体大小
File -----> Settings ------> Editor ------> Font -----> Size
切换注释颜色
默认为灰色看起来不明显,可以改为绿色
File -----> Settings ------> Editor ------> Language Defaults -----> Comments
自动导入缺少的包
File -----> Settings ------> Editor ------> General -----> Auto Import ------> (勾选)Add unambiguous 和 Otimize imports
提示时不分大小写
当输入代码只是大小写有误时,一样提示相关代码
File -----> Settings ------> Editor ------> General -----> Code Completion -----> (不勾选)Match case
IDEA中的AI工具
下载MarsCode AI插件:
在settings中的plungs中下载MarsCode AI插件,下载完成后登陆即可。
可以在代码中书写注释,然后AI便会依据注释生成代码,点击Tab键即可将AI写的代码输入到代码文件中。
当代码报错时:
点击位于IDEA控制面板右边的,AI图标,点击输入框中的#符号,选择出错的哪个代码文件,或者是类,然后把控制太报错信息粘贴到输入框中,AI会帮我们实时分析并更正错误。
登录MarsCode AI官网有刷题页面,没有思路时,可以点击右侧的AI,问他要一些思路提示,也可以要一些代码提示,帮助我们来刷题。
Tips:
idea中的一些快捷键:
Ctrl+B:查看源码
Ctrl+N:搜索java相关的java类等内容
Alt+7:自动列出当前源码的大纲
Ctrl+F12:当使用Ctrl+N搜索到想要的类后,可以按Ctrl+F12来选择查看该类中的哪个部分,也可以继续搜索当前部分源码的内容
Ctrl+Alt+M:即可把选中的代码块抽取成一个方法
Ctrl+D:按下后会自动把光标所在行的代码复制到下一行
Shift+Alt+下键:按下后把光标所在行的代码自动与下一行代码交换
Ctrl+p:查看方法参数
Shift x2:搜索相关内容进行查看
Ctrl+O:选择需要重写的方法
Ctrl+Alt+T: 选择需要添加的代码块,例如try{ },catch{ }语句
**Ctrl+H: **查看当前类的继承树,例如实现了哪些接口,继承了哪些类,继承与实现关系
Java的跨平台原理:
java是一门混合的语言,单纯的说它是解释型或者是编译型都是不对的
- Java语言的跨平台是通过虚拟机实现的。
- Java语言不是直接运行在操作系统里面的,而是运行在虚拟机中的。
- 针对于不同的操作系统,安装不同的虚拟机就可以了。
解释型语言:
对编写好的代码逐行翻译交给操作系统运行。
编译型语言:
对编写好的代码先编译成特定文件,然后交给操作系统运行。
Java 语言具备跨平台特性,主要依赖于 Java 虚拟机(JVM)和字节码(Bytecode)机制,下面来详细了解其原理:
1. 编译与执行流程
Java 程序的运行需要经历两个关键阶段:
- 编译阶段:Java 源代码(.java 文件)会被 Java 编译器(javac)编译成字节码文件(.class 文件)。这里的字节码是一种中间形式的代码,它并非直接能在硬件上运行的机器码。
- 运行阶段:字节码文件会在不同操作系统上的 Java 虚拟机(JVM)中运行。JVM 负责将字节码翻译成对应操作系统的本地机器码并执行。
2. 一次编写,到处运行
Java 实现跨平台的核心在于 “一次编写,到处运行”(Write Once, Run Anywhere)理念,其实现依赖于以下两点:
- 统一的字节码格式:不管在何种平台上编译 Java 源代码,生成的字节码文件格式都是相同的。
- 不同平台的 JVM 实现:针对 Windows、Linux、macOS 等不同的操作系统,Oracle 或其他厂商会开发出对应的 JVM 实现。这些 JVM 能够将统一的字节码翻译成特定平台的机器码。
3. JVM 的关键作用
JVM 是 Java 跨平台的核心组件,它承担着以下重要职责:
- 字节码解释与执行:JVM 负责加载字节码文件,并通过解释器或即时编译器(JIT)将字节码转换为本地机器码。
- 内存管理:JVM 管理着 Java 程序运行时的内存分配、垃圾回收等操作,与底层操作系统隔离开来。
- 平台适配:JVM 屏蔽了不同操作系统和硬件之间的差异,为 Java 程序提供了一个统一的运行环境。
4. 与其他语言的对比
和 C、C++ 等语言相比,Java 的跨平台机制有明显不同:
- C/C++:源代码编译后会直接生成特定平台的机器码,若要在其他平台运行,需要重新编译。
- Java:只需要编译一次生成字节码,就可以在任何安装了对应 JVM 的平台上运行。
5. 图示说明
下面用一个简单的流程示意图来展示 Java 的跨平台原理:
源代码 (.java)
|
▼
Java编译器 (javac)
|
▼
字节码 (.class)
|
▼
┌───────────────────┐
│ Windows JVM │
├───────────────────┤
│ Linux JVM │
├───────────────────┤
│ macOS JVM │
└───────────────────┘
|
▼
本地机器码执行6. 注意事项
- JRE 与 JDK:Java 运行时环境(JRE)包含了 JVM 和运行 Java 程序所需的核心类库,而 Java 开发工具包(JDK)则包含了 JRE 和开发工具。
- 版本兼容性:要保证字节码与 JVM 版本兼容,高版本 JVM 通常可以运行低版本编译的字节码,但低版本 JVM 可能无法运行高版本编译的字节码。
- 本地方法:如果 Java 程序中使用了本地方法(通过 JNI 调用本地库),那么程序的跨平台性就会受到限制,因为本地库是与特定平台相关的。
总结
Java 的跨平台特性正是通过 统一的字节码格式 和 平台特定的 JVM 实现 来实现的。
java的运行是在虚拟机(JVM)中运行的,java语言规定了统一的字节码文件格式,并开发了不同平台对应的不同类型的虚拟机(JVM),同时也通过虚拟机避免了编译与运行平台硬件不同带来的影响,然后通过将格式统一的字节码文件交给安装了虚拟机(JVM)的平台,来进行运行,达到了一次编译到处运行的特点。
关于环境变量:
在系统环境变量中配置环境变量,是操作系统中一项重要的设置,其核心作用是为系统和应用程序提供全局可访问的关键信息,简化操作、保证程序正常运行并提升系统灵活性。具体作用可从以下几个方面详细理解:
1. 让系统和程序快速定位可执行文件
这是环境变量最常用的功能之一。
例如,当我们在命令行输入 python 或 java 时,系统需要知道这些程序的安装路径才能执行。若将程序的安装目录(如 C:\Python39 或 C:\Program Files\Java\jdk1.8.0_301\bin)添加到环境变量 Path 中,系统会自动在这些路径中搜索对应的可执行文件(如 python.exe、java.exe),无需每次输入完整路径(如 C:\Python39\python.exe)。
- 好处:简化命令行操作,避免重复输入冗长路径;确保程序在任意目录下都能被调用。
2. 为应用程序提供全局配置信息
许多程序运行时需要依赖特定的参数或路径(如临时文件目录、配置文件位置、依赖库路径等),这些信息可通过环境变量全局传递,无需在程序内部硬编码。
- 举例:
- 环境变量 TEMP 或 TMP 指向系统临时文件夹,程序会自动使用该路径存储临时文件,避免每个程序单独定义临时路径。
- 开发工具(如 IDE)常通过环境变量(如
M2_HOME指向 Maven 安装目录、NODE_PATH指向 Node.js 模块路径)定位依赖资源,确保工具正常工作。
3. 实现跨程序、跨用户的信息共享
环境变量分为 系统级 和 用户级:
- 系统级环境变量:对所有用户和程序生效,适合存储全局通用的信息(如系统目录路径
SystemRoot,通常指向C:\Windows)。 - 用户级环境变量:仅对当前用户生效,可用于个性化配置(如用户文档路径
USERPROFILE)。
这种机制让不同程序、不同用户能共享同一套基础配置,同时保留个性化设置的灵活性。
4. 简化软件安装与迁移
当软件需要依赖其他组件时,通过环境变量指定依赖的位置,可避免软件安装时 “硬编码” 路径。
例如,某程序依赖 Java 运行环境,只需读取环境变量 JAVA_HOME 即可找到 Java 安装目录,无需关心用户具体安装在 C盘 还是 D盘。若后续迁移软件或更换依赖路径,只需修改环境变量,无需重新安装或修改程序本身。
5. 支持系统和程序的动态配置
环境变量的值可以随时修改,修改后无需重启系统(部分程序可能需要重启生效),程序即可读取到新的配置。
这在开发、测试场景中尤为重要:例如,通过修改环境变量 DEBUG 的值(如 DEBUG=true),可让程序切换到调试模式,无需重新编译代码。
总结
环境变量本质是 操作系统提供的 “全局信息枢纽”,通过统一管理关键路径、配置参数等信息,降低了程序之间的耦合度,简化了用户操作,同时保证了系统和应用的稳定性与灵活性。无论是日常使用(如命令行操作)还是开发部署(如依赖管理),环境变量都是不可或缺的核心机制。
简单来说:
也就是说配置好环境变量后,一些应用程序在需用到其他文件时,可以不用特意指定,应用程序可依据在系统中配置好的环境变量来灵活的使用所需要的文件
配置环境变量后,应用程序在运行时会自动从环境变量中读取所需的信息(如文件路径、参数值等),无需在每次调用时显式指定这些信息。这种机制让程序更加灵活和可移植,因为它们不再依赖于固定的文件路径或硬编码的配置,而是通过环境变量动态获取所需资源。
具体场景举例:
- 依赖库的加载
例如,Python 程序可能依赖第三方库(如numpy),这些库通常安装在site-packages目录中。Python 解释器通过环境变量PYTHONPATH来确定搜索这些库的路径,无需程序开发者手动指定每个库的位置。 - 系统工具的调用
当你在命令行中输入git commit时,系统会通过PATH环境变量找到 Git 程序的安装路径(如C:\Program Files\Git\bin\git.exe),然后执行该命令。如果没有配置PATH,你需要每次都输入完整的路径才能使用 Git。 - 配置文件的位置
应用程序可能需要读取配置文件(如数据库连接信息),通过环境变量指定配置文件的路径(例如CONFIG_PATH=/etc/myapp/config.ini),程序可以在运行时动态加载该文件,而不是将路径硬编码在代码中。
优势总结:
- 灵活性:当文件位置或配置发生变化时,只需修改环境变量,无需修改程序代码。
- 可移植性:同一程序可以在不同环境中运行,只需调整环境变量即可适应不同的文件结构。
- 安全性:敏感信息(如数据库密码)可以通过环境变量传递,避免在代码中明文存储。
总之,环境变量就像是程序与系统之间的 “约定”,让程序能够更智能地获取所需资源,而无需用户或开发者每次都手动干预。
PATH 环境变量
一、PATH 环境变量的作用
PATH是一个特殊的环境变量,其主要功能是告诉操作系统,当用户在命令行中输入一个命令(比如java、python)时,应该去哪些目录里查找对应的可执行文件(像.exe、.bat这类文件)。打个比方,要是你在命令行输入python并按下回车键,系统就会按照PATH中设定的目录顺序依次进行查找,一旦在某个目录中找到python.exe文件,就会执行该文件;要是所有目录都找遍了还没找到,就会提示 “命令未找到” 的错误。
配置PATH的好处有很多。它可以让用户不用每次都输入命令的完整路径,大大提高了使用效率。例如,你不用每次都输入C:\Program Files\Python39\python.exe,直接输入python就能运行 Python 解释器。
二、% MAVEN_HOME%\bin 格式的含义
%MAVEN_HOME%\bin这种格式在 Windows 系统中较为常见,它其实是对环境变量的引用。这里的%MAVEN_HOME%代表的是另一个环境变量,这个变量通常指向 Maven 的安装目录,比如C:\apache-maven-3.8.4。而\bin则表示该安装目录下的bin子目录,Maven 的可执行文件(像mvn.exe)就存放在这个子目录中。
下面通过一个具体例子来说明。
假设你已经将MAVEN_HOME环境变量设置为C:\apache-maven-3.8.4,那么%MAVEN_HOME%\bin实际上就相当于C:\apache-maven-3.8.4\bin。当你把%MAVEN_HOME%\bin添加到PATH环境变量中之后,在命令行输入mvn -v时,系统就能找到并执行C:\apache-maven-3.8.4\bin\mvn.exe这个文件。
三、使用这种格式的优势
- 便于维护:如果 Maven 升级了,需要更改安装目录,你只需要修改
MAVEN_HOME环境变量的值就行,不用去动PATH变量。 - 提高可读性:使用
%MAVEN_HOME%\bin能让PATH变量的内容更加清晰,一看就知道这是和 Maven 相关的路径。 - 跨平台兼容:在 Linux 或者 macOS 系统中,类似的写法是
$MAVEN_HOME/bin,原理是一样的,只是语法有所不同。
四、配置示例
下面以 Windows 系统为例,介绍一下配置步骤:
- 设置
MAVEN_HOME环境变量:- 右键点击 “此电脑”,选择 “属性”。
- 点击 “高级系统设置”。
- 在弹出的窗口中点击 “环境变量”。
- 在 “系统变量” 区域,点击 “新建”,变量名填
MAVEN_HOME,变量值填 Maven 的安装目录,比如C:\apache-maven-3.8.4。
- 将
%MAVEN_HOME%\bin添加到PATH中:- 在 “系统变量” 区域找到
PATH变量,点击 “编辑”。 - 点击 “新建”,然后输入
%MAVEN_HOME%\bin。 - 按照顺序依次点击 “确定” 保存设置。
- 在 “系统变量” 区域找到
配置完成之后,打开一个新的命令提示符窗口,输入mvn -v,如果能看到 Maven 的版本信息,就说明配置成功了。
五、其他注意事项
- 在 Linux 和 macOS 系统中,环境变量的引用语法是
$MAVEN_HOME/bin,并且通常是在.bashrc、.zshrc或者/etc/profile这些文件中进行配置。 - 配置环境变量之后,需要重启命令行窗口或者相关的应用程序,新的配置才能生效。
- 要确保引用的环境变量(如
MAVEN_HOME)确实已经存在,否则PATH中就会出现无效的路径。
PATH 环境变量与 普通环境变量的区别
一、核心功能不同
- PATH环境变量:
是系统级的 “可执行文件搜索路径列表”,专门用于告诉操作系统:当用户在命令行输入一个命令(如java、mvn)时,应该到哪些目录中查找对应的可执行文件(.exe、.bat等)。
例如,输入java -version时,系统会遍历PATH中的目录,找到java.exe并执行。 - JAVA_HOME等普通环境变量:
是用户或程序自定义的 “路径标记”,主要用于存储某个软件的安装目录(或其他关键路径),方便程序或脚本引用。
例如,JAVA_HOME通常指向 JDK 的安装目录(如C:\Program Files\Java\jdk1.8.0_301),其本身不参与系统的命令搜索,而是供其他程序(如 IDE、Tomcat)或脚本读取使用。
二、作用场景不同
| 对比维度 | PATH 环境变量 | JAVA_HOME 等普通环境变量 |
|---|---|---|
| 使用者 | 主要是操作系统(命令行解释器) | 主要是应用程序、脚本或开发工具(如 IDE) |
| 核心目的 | 简化命令输入(无需写完整路径即可执行命令) | 统一存储软件安装路径,方便程序读取和维护 |
| 依赖关系 | 不依赖其他环境变量(但可引用其他变量简化配置) | 可能被PATH或其他程序引用,但自身独立存在 |
三、配置方式与语法不同
- PATH的配置:
内容是多个目录的集合,目录之间用分隔符隔开(Windows 用;,Linux/macOS 用:)。
例如(Windows):PATH=C:\Windows\system32;%JAVA_HOME%\bin;%MAVEN_HOME%\bin
这里%JAVA_HOME%\bin是引用了JAVA_HOME变量,本质上等价于C:\Program Files\Java\jdk1.8.0_301\bin。 - JAVA_HOME的配置:
内容是单一的路径(软件安装目录),无需分隔符。
例如(Windows):JAVA_HOME=C:\Program Files\Java\jdk1.8.0_301
四、依赖关系示例
JAVA_HOME和PATH常配合使用,但逻辑上是 “PATH引用JAVA_HOME”,而非两者功能重叠:
- 先配置
JAVA_HOME存储 JDK 路径:JAVA_HOME=C:\jdk1.8 - 再将
%JAVA_HOME%\bin添加到PATH中,等价于将C:\jdk1.8\bin加入搜索路径。 - 此时在命令行输入
java,系统会通过PATH找到C:\jdk1.8\bin\java.exe并执行;同时,IDE(如 Eclipse)会读取JAVA_HOME来确定 JDK 位置,用于编译项目。
总结
PATH是系统的 “命令搜索引擎”,负责定位可执行文件,让用户能直接输入命令运行程序。JAVA_HOME等是 “路径标记牌”,负责存储软件安装路径,供其他程序或PATH引用,简化配置和维护。- 两者分工不同:
PATH解决 “如何方便地执行命令”,JAVA_HOME解决 “如何统一管理软件路径”。
Java的基础知识
return 的作用
- 结束一个方法
- 给方法返回一个值
包的使用
包的作用
包就是文件夹,用来管理各种不同功能的Java类
包名书写的规则
公司域名反写+包的作用,需要全部英文小写,见名知意
什么是全类名
包名+类名
什么时候需要导包?什么时候不需要导包?
- 使用同一个包中的类时,不需要导包,
- 使用java.lang包中的类时,不需要导包。
- 其他情况都需要导包
- 如果同时使用两个包中的同名类,需需要用全类名。
常用关键字
instanceof 关键字
可判断对象是否为同一类型
final 关键字
- 方法:表明该方法是最终方法,不能被重写
- 类:表明该类是最终类,不能被继承
- 变量:叫做常量,只能被赋值一次
常量
实际开发中,常量一般作为系统的配置信息,方便维护,提高可读性。
常量的命名规范:
- 单个单词:全部大写
- 多个单词:全部大写,单词之间用下划线隔开
细节:
- final修饰的变量是基本类型:那么变量存储的数据值不能发生改变。
- final修饰的变量是引用类型:那么变量存储的地址值不能发生改变,对象内部的可以改变。
键盘录入
键盘录入是采用Scanner类中的方法来实现的:
- next:输入字符串
- nextInt:输入整数
- nextDouble:输入小数
以上三个都会在碰到空格,制表符Tab以及回车键时停止录入
- nextLine:输入字符串(优势:碰到空格,制表符Tab不会停止,只会在碰到回车键Enter时停止录入)
用nextLine输入数据时全是字符串,需用parseXXX函数转换成对应的需要的数据类型的包装类,
String str2=sc.nextLine();
int num1=Integer.parseInt(str2); //转换成整数
Double dou1=Double.parseDouble(str2); //转换成小数注意:
当用next,nextInt,nextDouble进行录入数据时,若用空格分割,则在输入完所有数据之前,数据是不会进行录入的,
当输入完所有数据后,再进行自动匹配
实例如下:
//定义一个集合,添加数字并遍历
Scanner sc=new Scanner(System.in);
System.out.print("请输入想添加的数据个数:");
int n=sc.nextInt();
//添加n个数据
for(int i=0;i<n;i++){
//输入数据
System.out.println("请输入想添加的数据:");
Integer num=sc.nextInt();
//把数据添加到集合中
integerArrayList.add(num);
}
//控制台输出结果如下:
//当输入数据用空格隔开时:
请输入想添加的数据个数:5 1 2 3 4 5
请输入想添加的数据:
请输入想添加的数据:
请输入想添加的数据:
请输入想添加的数据:
请输入想添加的数据:
//如上所示,输入数据之间用空格隔开,当输入完所有数据时,在对数据进行录入,
//若是输入数据之后按回车,则直接把数据录入到相应的位置
请输入想添加的数据个数:5
请输入想添加的数据:
1
请输入想添加的数据:
2
请输入想添加的数据:
3
请输入想添加的数据:
4
请输入想添加的数据:
5链式编程
当我们在调用一个方法的时候,不需要用变量接收他的结果,可以继续调用其他方法。
例如
public static void main(String[] args){
/*
搜先getString得到一个String的返回值,不单独定义一个返回值变量,直接来调用String类中的方法,
也就是返回值类型的方法
*/
int 1en = getString().substring(1).replace( target:"A", replacement: "Q").length();
System.out.println(len);
}
public static String getString( ){
Scanner sc = new Scanner(System.in);
System.out.println("请输入一个字符串");
String str = sc.next();
return str;
}注释类型
- 单行注释
//单行注释- 注释多行注释
/*
多行注释
*/- 文档注释当我们在代码中用文档注释时,后续鼠标放到相关方法上时,就会出现文档注释的内容,方便理解,不需要来回切换文件就可以查看代码相关注释一般在写项目时使用,平常写题代码量较小,单行和多行注释即可满足需求
/**
*这是一个工具类
*
*@author 尼古拉斯
*@version 1.0
*/
public class ArrayUtil {
/**
*获取数组中的最大值
*@param arr 参数是数组,表示求这个数组中的最大值
*@return 返回数组中的最大值
*/
public static int getMax(int[] arr){
int max = arr[0];
for(int i = 0;i< arr.length; i++){
if(arr[i]>max){
max = arr[i];
}
}
return max;
}
}字面量(基本数据类型)
| 字面量类型 | 说明 | 举例 |
|---|---|---|
| 整数类型 ( int ) | 不带小数点的数字 | 666, -88 |
| 小数类型 ( double,float ) | 带小数点的数字 | 13.14,-5.21 |
| 字符串类型 ( string ) | 用双引号括起来的内容 | "HelloWorld" ,"黑马程序员" |
| 字符类型 ( char ) | 用单引号括起来的,内容只能有一个 | 'A','0',‘我' |
| 布尔类型 ( boolean ) | 布尔值,表示真假 | 只有两个值:true,false |
| 空类型 ( null ) | 一个特殊的值,空值 | 值是:null |
随机数生成
不同进制数字表现形式
不同进制在代码中的表现形式:
- 二进制:由 0 和 1 组成,代码中以 0b 开头
- 十进制:由 0~9 组成,前面不加任何前缀
- 八进制:由 0~7 组成,代码中以 0 开头
- 十六进制:由 09 还有 af 组成,代码中以 0x 开头
进制转换
任意进制转十进制
公式:系数*(基数的权次幂)相加
- 系数:就是每一位上的数
- 基数:当前进制数
- 权:从右往左,依次为012345
例如:二进制 101 转为十进制,
从左往右系数依次为 1,0,1;因为是二进制数,所以基数为 2;权是从右往左依次为 0,1, 2
依据公式:系数*(基数的权次幂)相加可得
二进制数101转为十进制数为:1×22 + 0×21 + 1×2^0 = 5
同理,八进制数101转为十进制数为:1×82 + 0×81 + 1×8^0 = 65
二转十的简便方法
8421快速转换法:即每个二进制数字上的一转换为十进制对应的数字相加,若为零则跳过,得到的结果即为二进制转换为十进制后的数据。
从右往左,二进制不同位置上的 1 对应得十进制数依次为:1,2,4,8,16,32,64,128
例如:
二进制1101,从右往左,每个一转换为十进制为1,2,4,8,但是第二个位置为0,所以跳过不管,其他的1转换为十进制之后的数据相加为:1+4+8=13,所以二进制数1101转换为十进制为13
十进制转其他进制
除基取余法:不断的除以基数(几进制,基数就是几)得到余数,直到商为0,再将余数倒着拼起来即可
例如:
十进制数11转为二进制数,依据除基取余法可得,基数为2
| 11
2 |______
| 5(商)......1(余数)
2 |______
| 2(商).....1(余数)
2 |_______
| 1(商).....0(余数)
2 |______
0(商).....1(余数)
除基取余法:不断的除以基数(几进制,基数就是几)得到余数,直到商为0,再将余数倒着拼起来即可所以,十进制数 11 转为二进制数为 1011
计算机的存储规则
计算机中是依据二进制来存储数据,英文及一些键盘上的字符对应的是ASII码表。
中文的如下:
- B2312编码:1981年5月1日发布的简体中文汉字编码国家标准。收录7445个图形字符,其中包括6763个汉字。
- BIG5编码:台湾地区繁体中文标准字符集,共收录13053个中文字,1984年实施。
- GBK编码:2000年3月17日发布,收录21003个汉字,包含国家标准GB13000-1中的全部中日韩汉字,和BIG5编
码中的所有汉字。 - Unicode编码:国际标准字符集,它将世界各种语言的每个字符定义一个唯一的编码,以满足跨语言、跨平台的文
本信息转换。
标识符的命名规则
- 由数字、字母、下划线(_)和美元符($)组成
- 不能以数字开头
- 不能是关键字
- 区分大小写
JAVA中的命名规则
1.小驼峰命名法:
用来命名**方法**和**变量**:
(1)当标识符由一个单词组成时,全部用小写字母
(2)当标识符为多个单词组成时,第一个单词用小写字母,后面的单词的首字母大写,其余为小写
2.大驼峰命名法:
用来命名**类**:
无论几个字母组成,单词首字母都大写
命名规则(必须遵守):
- 合法字符:包名只能由字母、数字、下划线(_)和点号(.)构成。不过,数字不能作为包名的开头,点号也不能连续出现,同时包名里不能包含空格。
- 小写字母:按照 Java 的规范,包名要全部使用小写字母,这样可以避免和类名或者接口名产生混淆
- 域名反转(推荐):通常会采用开发者或组织的互联网域名反转后的形式作为包名的前缀,这样能保证包名在全球范围内的唯一性。
命名规范(建议遵守):
- 使用有意义的名称:包名要能够清晰地体现出其功能或者用途。
// 好的示例
package com.example.math; // 数学工具
package com.example.ui.widgets; // UI组件
// 避免使用无意义的名称
package com.example.util1;- 避免使用 Java 保留字:包名不能是 Java 的保留字,像
class、int、public等都不可以。 - 层级结构:要合理使用点号(.)来创建逻辑上的层级结构,一般按照 “组织名。项目名。模块名” 这样的顺序来排列。
// 典型的包结构 com.组织名.项目名.模块名
package com.example.ecommerce.cart; // 购物车模块
package com.example.ecommerce.payment; // 支付模块常见错误与注意事项:
- 文件路径要和包名对应:Java 源文件里的包声明必须和文件所在的目录结构相匹配
// 对于包声明 package com.example.util;
// 源文件应该位于项目目录/com/example/util/ 路径下- 避免过长的包名:虽然包名可以很长,但最好保持简洁明了,过长的包名会让代码变得难以阅读。
- 特殊情况:
package com.example.internal;package com.example.util.test;示例:
以下是一个典型的项目包结构示例:
package com.mycompany.app; // 根包
package com.mycompany.app.model; // 数据模型
package com.mycompany.app.service; // 业务逻辑
package com.mycompany.app.dao; // 数据访问
package com.mycompany.app.ui; // 用户界面- 对于内部使用的包,可以使用`internal`或者`impl`这样的后缀。
- 测试类通常会放在和被测试类相同的包路径下,但使用`test`作为后缀。
运算符的运算
不同类型数据运算
数字进行运算时,数据类型不一样不能运算,需要转成一样的,才能运算。、
取值范围:
byte < short < int < long < float < double
隐式转换:取值范围小的数值转换为取值范围大的数值。取值范围小的,和取值范围大的进行运算,小的会先提升为大的(系统自动转换),再进行运算。byte,short,char三种类型的数据在运算的时候,都会直接先提升为int,然后再进行运算。
强制类型转换:取值范围大的数值转换为取值范围小的数值。如果把一个取值范围大的数值,赋值给取值范围小的变量。是不允许直接赋值的。如果一定要这么做就需要加入强制转换。格式:目标数据类型变量名=(目标数据类型)被强转的数据;
字符串中的+
当"+”操作中出现字符串时,这个“+”是字符串连接符,而不是算术运算符了。它会将前后的数据进行拼接,并产生一个新的字符串。例如: "123"+123 ===> "123123"
连续进行”+“操作时,从左到右逐个执行。例如: 1+99+"黑马" =====> "100黑马"
字符中的+
当 字符+字符 或者 字符+数字 时,把字符通过ASCII码表查询到对应的数字再进行计算
逻辑运算符
| 符号 | 作用 | 说明 |
|---|---|---|
| & | 逻辑与 (且) | 并且,两边都为真,结果才是真,( 两边条件都满足 ) |
| 逻辑或 | ||
| ^ | 逻辑异或 | 相同为 false,不同为 true,(两边条件不相同) |
| ! | 逻辑非 | 取反 |
短路逻辑运算符
| 符号 | 作用 | 说明 |
|---|---|---|
| && | 短路与 | 结果和&相同,但是有短路效果:(即当前面表达式为假,整个表达式为假,不再判断第二个表达式,因为第一个为假时,无论第二个表达式,为真还是假,整个表达式都为假,只有都为真时才为真) |
注意
&,|:论左边true,false,右边都要执行
&&,||:如果左边能确定整个表达式的结果,右边不执行。
&&:左边为false,右边不管是真是假,整个表达式的结果一定是false。
||:左边为true,右边不管是真是假,整个表达式的结果一定是true
这两种情况下,右边不执行,提高了效率。
三元运算符
格式:关系表达式?表达式1:表达式2;
计算规则:
- 首先计算关系表达式的值
- 如果值为
true,表达式1的值就是运算结果 - 如果值为
false表达式2的值就是运算结果
==号进行比较属性时的作用
基本数据类型
使用==号时是对数据的值的大小的比较,看值是否相等,与地址无关
引用数据类型
使用== 号时是对地址的比较,看地址是否相等
特殊类型String
String是引用数据类型,但是由于字符串常量池的缓存机制,使用==号时要格外注意,这与字符串的创建以及存储机制有关。
- 直接赋值的字符串(如
String s = "abc";):会被存储在字符串常量池中,相同内容的字符串会复用同一个对象,以节省内存,这时两个字符串地址相同。- 通过
new创建的字符串(如String s = new String("abc");):会在堆内存中创建一个新对象,即使内容与常量池中的字符串相同,也会生成独立的对象(可以通过intern()方法将其加入常量池实现复用)。这时虽然字符串内容相同但是地址不同。
String s1 = "abc";
String s2 = "abc"; // 从常量池获取,和 s1 指向同一个对象
String s3 = new String("abc"); // 新创建对象,地址不同
String s4 = s3.intern(); // 手动将 s3 的对象存入常量池,返回常量池引用
System.out.println(s1 == s2); // true(地址相同,同属常量池对象)
System.out.println(s1 == s3); // false(地址不同,s3 是堆中新对象)
System.out.println(s1 == s4); // true(s4 指向常量池的 s1 对象)结论:比较 String 的「内容是否相等」需用 **<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">equals()</font>****<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">String</font>****<font style="color:rgb(0, 0, 0);background-color:rgba(0, 0, 0, 0);">equals()</font>**
- ****`****`****
- ``
- ``
原码反码补码
原码
十进制数据的二进制表现形式,最左边是符号位,0为正,1为负。
原码的弊端
利用原码进行计算的时候,如果是正数完全没有问题。
但是如果是负数计算,结果就出错,实际运算的方向,跟正确的运算方向是相反的。
反码
为了解决原码不能计算负数的问题而出现的
反码的计算规则
正数的反码不变,负数的反码在原码的基础上,符号位不变。数值取反,0变1,1变0,
负数计算用反码,计算之后再把得到的反码转换为原码,即可得到计算的结果
反码的弊端
负数运算的时候,如果结果不跨0,是没有任何问题的,但是如果结果跨0,跟实际结果会有1的偏差。
补码
为了解决负数计算时跨0的问题而出现的
补码的计算规则
补码 = 反码 + 1
正数的补码不变,负数的补码在反码的基础上+1。
另外补码还能多记录一个特殊的值-128,该数据在1个字节下,没有原码和反码。
补码的注意点
计算机中的存储和计算都是以补码的形式进行的。
以反码补码理解数据类型转换
1 byte占1个字节也就是0000 0000八个bit位其中第一位表示正负符号,0 为正,1 为负号。
1 short类型占 2 个字节也就是 16 个 bit 位。
1 int占4个字节也就是 0000 0000 0000 0000 0000 0000 0000 0000 32 个bit位。
1 long占 8 个字节也就是 64 个 bit 位。
public class Test {
public static void main(String[] args) {
int a = 300; // 0000 0000 0000 0000 0000 0001 0010 1100
byte b = (byte)a; //大范围数据强制转换为小范围数据,把多出来的高位字节全去掉
System.out.println(b); // b ==> 0010 1100 因为 1 byte 只占 1 字节即 8 个bit位
}
}
//因为转换后第一个位置位 0 即表示数据为正数,所以直接把二进制数 0010 1100 转为十进制就为转换而来的数据
public class Test {
public static void main(String[] args){
int a = 200; // 0000 0000 0000 0000 0000 0000 1100 1000
byte b =(byte)a; //转换之后去掉多出的高位bit位 得出 b ==> 1100 1000
System.out.println(b);
}
}
//第一个位表示符号,为 1 所以转换后为负数,计算机的数据存储以及计算都是补码形式,得到的也是负数的补码,转换为十进制数字后为 -56 即 b 的值,b = -56依据补码求对应的十进制数:
步骤 1:判断符号位
补码的最高位(第 7 位,从 0 开始计数)为1,说明这是一个负数。
步骤 2:求补码的 “原码”
对于负数的补码,需先转换为原码(原码逆转为反码,反码 + 1 为补码 ,即 “补码 - 1 后再取反”):
- 补码减 1:
1100 1000 - 1 = 1100 0111(二进制减法) - 对结果取反(符号位不变,其他位 0 变 1、1 变 0):
1011 1000(这就是原码)。
步骤 3:原码转换为十进制
原码1011 1000中,符号位 “1” 表示负数,数值位为011 1000
- 数值位计算:
0×2⁶ + 1×2⁵ + 1×2⁴ + 1×2³ + 0×2² + 0×2¹ + 0×2⁰ = 32 + 16 + 8 = 56。 - 结合符号位为 1:结果为
-56。
结论
补码1100 1000对应的十进制数是 -56。
(补充:若补码最高位为 0(正数,正数的原,补,反码都一样),直接按无符号二进制转换为十进制即可,无需求原码。)
逻辑运算符用于数字计算的情况
逻辑与&
运算规则:
把需要运行的数据转换为补码形式,然后依据数据类型把各个bit位的数据都写出来,上下对照,然后依据逻辑与&的运算特点(两者都为真才为真),上下对照的数据都为1时,结果为1否则为0。
public class Test {
public static void main(String[] args) {
int a = 200;
int b = 10;
System.out.println(a & b); //用逻辑与运算符进行计算,结果为 8
}
}
/*
计算过程如下:
200对应二进制数为: 0000 0000 0000 0000 0000 0000 1100 1000
用逻辑与运算: &
10对应二进制数为: 0000 0000 0000 0000 0000 0000 0000 1010
_____________________________________________
结果为: 0000 0000 0000 0000 0000 0000 0000 1000
上下对照的数据都为`1`时,结果为`1`否则为`0`
*/逻辑或|
运算规则:
把需要运行的数据转换为补码形式,然后依据数据类型把各个bit位的数据都写出来,上下对照,然后依据逻辑或|的运算特点(两者都为假才为假),上下对照的数据都为0时,结果为0否则为1。
public class Test {
public static void main(String[] args) {
int a = 200;
int b = 10;
System.out.println(a | b); //用逻辑或运算符进行计算,结果为 202
}
}
/*
计算过程如下:
200对应二进制数为: 0000 0000 0000 0000 0000 0000 1100 1000
用逻辑与运算: &
10对应二进制数为: 0000 0000 0000 0000 0000 0000 0000 1010
_____________________________________________
结果为: 0000 0000 0000 0000 0000 0000 1100 1010
上下对照的数据都为`0`时,结果为`0`否则为`1`
*/左移运算符<<
运算规则:
计算出数据的补码后,把各个bit位数据都写出来,然后向左移动,超出数据类型的边界位置的不要了,右边空出来的位置补上 0。(左移一次就相当于乘 2,两次相当于乘4 )
public class Test {
public static void main(String[]args){
int a = 200;
System.out.println(a << 2); //用左移运算符计算,结果为 800,左移两次,乘 4
}
}
/*
计算过程如下:
200对应二进制数为: |0000 0000 0000 0000 0000 0000 1100 1000|
用左移运算符移动后: 00|0000 0000 0000 0000 0000 0000 1100 10`00`|
|符号代表的是int类型包含的四个字节位置边界(边界不会动),左边的左移后多出两个不要了,右边空的两个位置,补‘0’
*/右移运算符>>
运算规则:
计算出数据的补码后,把各个bit位数据都写出来,然后向右移动,超出数据类型的边界位置的不要了,右边空出来的位置补上 0或者1。(右移一次就相当于除 2,两次相当于除4,数据原来为正就再第一位补0,为负就补1 )
public class Test {
public static void main(String[]args){
int a = 200;
System.out.println(a >> 2); //用右移运算符计算,结果为 50,右移两次,除 4
}
}
/*
计算过程如下:
200对应二进制数为: |0000 0000 0000 0000 0000 0000 1100 1000|
用左移运算符移动后: |`00`00 0000 0000 0000 0000 0000 1100 10|00
|符号代表的是int类型包含的四个字节位置边界,边界是不动的,右边的右移后多出的两个不要了,左边空的两个位置,第一个补‘0’(原来的数据为正数,要是为负数就再第一位补 1),第二位补 0
*/无符号右移>>>
运算规则:
与右移运算符运算规则类似,唯一区别就是右移后,无论原本数据为正还是为负,均在高位bit位补0
数组
数组的初始化方式
Java 中创建数组主要有以下三种方式:
动态初始化(指定长度):
数据类型[] 数组名 = new 数据类型[数组长度];
示例:int[] arr = new int[5];静态初始化(指定元素):
数据类型[] 数组名 = new 数据类型[]{元素1, 元素2, ...};
示例:int[] arr = new int[]{1, 2, 3};静态初始化简化版:
数据类型[] 数组名 = {元素1, 元素2, ...};
示例:int[] arr = {1, 2, 3};
静态初始化:
完整格式:数据类型 [ 数组名 ] = new 数据类型 [ ]{元素1,元素2,元素3...} ;
简化格式:数据类型 [ ] 数组名 = {1,2,3,4,5 } ;
动态初始化:
数据类型 [ ] 数组名 = new 数据类型 [ 数组长度 ] ;
字符数组与字节数组
字符数组(char[])和字节数组(byte[])是 Java 中两种常用的数组类型,核心区别在于存储的数据类型、用途和编码方式,具体如下:
1. 存储的数据类型不同
- 字符数组(char[ ]):
每个元素是一个 char 类型的数据(占 2 个字节),直接存储 Unicode 字符(涵盖几乎所有语言的字符,包括中文、英文、符号等)。
例如:char[] chars = {'A', '中', '€'};中,'A'对应 Unicode 编码0x0041,'中'对应0x4E2D,直接以字符形式存储。 - 字节数组(byte[ ]):
每个元素是一个 byte 类型的数据(占 1 个字节,范围-128~127),存储的是二进制数据(原始字节),不直接对应字符。
例如:byte[] bytes = {65, -28, -72, -83};中,65是 ASCII 码中'A'的字节值,而-28, -72, -83可能是某个字符(如'中')经过编码后的字节序列。
2. 用途不同
- 字符数组(char[ ]):
主要用于 直接处理字符序列,适合需要操作 Unicode 字符的场景,例如:- 字符串的底层存储(
String内部就是char[],JDK 9 后改为byte[]配合编码标记,但逻辑上仍以字符为单位)。 - 字符级别的操作(如拼接、替换、截取等),常见于
StringBuilder、StringBuffer的内部实现。
- 字符串的底层存储(
- 字节数组(byte[ ]):
主要用于 处理二进制数据,适合需要传输、存储原始数据的场景,例如:- 文件读写(图片、视频、文本等所有文件本质都是字节流)。
- 网络通信(数据以字节形式在网络中传输)。
- 字符编码转换(将
char[]按指定编码规则转为byte[],或反之)。
3. 与编码的关系不同
- 字符数组(char[]):
与编码方式无关,因为char本身就是 Unicode 字符的直接表示(Unicode 是字符集,定义了字符与编码值的映射)。
例如,'中'在char[]中永远是0x4E2D,无论后续用 UTF-8、GBK 还是其他编码转换为字节,char[]中的值不变。 - 字节数组(byte[]):
与编码方式密切相关。同一字符序列按不同编码规则转换为byte[]时,结果不同:- 例如,
"中"按 UTF-8 编码是[-28, -72, -83](3 个字节),按 GBK 编码是[-42, -48](2 个字节),按 ASCII 编码则无法表示(会丢失数据)。
- 例如,
4. 转换关系
字符数组和字节数组可以通过编码 / 解码相互转换:
char[]→byte[]:通过String.getBytes(Charset)编码(指定字符集,如 UTF-8、GBK)。byte[]→char[]:通过new String(byte[], Charset)解码(需与编码时的字符集一致,否则可能出现乱码)。
总结
| 维度 | 字符数组(char[1) | 字节数组(byte[]) |
|---|---|---|
| 元素类型 | char (2 字节,Unicode 字符) | byte(1字节,二进制数据) |
| 用途 | 直接处理字符序列 | 处理二进制数据 (存储、传输) |
| 与编码关系 | 无关 (Unicode 直接表示) | 密切相关 (依赖编码规则) |
| 典型场景 | 字符串操作、字符拼接 | 文件读写、网络传输、编码转换 |
简单来说:char[ ]是“字符的容器",byte[ ]是 "二进制数据的容器 ",两者通过编码规则建立联
系。
Java的内存分配
- 栈:方法运行时使用的内存,比如main方法运行,进入方法栈中执行
- 堆:存储对象或者数组,new来创建的,都存储在堆内存
- 方法区 :存储可以运行的class文件
- 本地方法栈:JVM在使用操作系统功能的时候使用,和我们开发无关
- 寄存器:给CPU使用,和我们开发无关
注意
- 只要是new出来的一定是在堆里面开辟了一个小空间
- 如果new了多次,那么在堆里面有多个小空间,每个小空间中都有各自的数据
- 从JDK8开始,取消方法区,新增元空间。把原来方法区的多种功能进行拆分有的功能放到了堆中,有的功能放到了元
空间中。
方法的重载
- 在同一个类中,定义了多个同名(返回值类型,参数都可以不同)的方法,这些同名的方法具有同种的功能
- 每个方法具有不同的参数类型或参数个数,这些同名的方法,就构成了重载关系
- 简单记:同一个类中,方法名相同,参数不同的方法。与返回值无关。
参数不同:个数不同、类型不同、顺序不同
Java虚拟机会通过参数的不同来区分同名的方法,依据传入参数来确定需要调用的是哪个重载方法。
两种数据类型
基本数据类型
数据值是存储在自己的空间中
特点:赋值给其他变量,也是赋的真实的值
引用数据类型
数据值是存储在其他空间中,自己空间中存储的是地址值。
特点:赋值给其他变量,赋的地址值
注意
方法的参数传递过程中,实参对于形参的方法传递也是这样的
方法的定义
方法嵌套定义:Java 不允许在一个方法内部定义另一个方法,这与某些其他编程语言(如 JavaScript)不同。
例如,下面这种写法是错误的:
public class Demo01 {
public static void main(String[] args) {
// 错误:不能在main方法内部定义turnToString方法
public static String turnToString(int [] arr) {
// 方法体
}
// 其他代码
}
}静态方法只能直接调用静态方法
在 Java 中,静态方法(static)只能直接直接调用其他静态方法,而不能直接调用非静态方法(实例方法)。
public class Demo01 {
public static void main(String[] args) {
int[] arrInt={1,2,3,4,5};
String result=turnToString(arrInt);
System.out.println(result);
}
public static String turnToString(int [] arr){
if(arr==null){
return "";
}
if(arr.length==0){
return "";
}
String result="[";
for (int i = 0; i < arr.length ; i++) {
result=result+arr[i];
}
return result+"]";
}
}在上述代码中,main方法是一个静态方法(被static修饰),当你在main中直接调用turnToString方法时:
- 如果
turnToString是静态方法(带static),可以直接调用(符合静态方法调用静态方法的规则) - 如果
turnToString是非静态方法(不带static),就会编译错误
原因分析:
- 静态方法属于类,不需要创建对象就能调用(比如
main方法) - 非静态方法属于对象,必须通过类的实例(对象)才能调用
当你去掉static修饰符后,turnToString就变成了实例方法,而main是静态方法,在静态上下文中不能直接调用实例方法(因为此时可能还没有创建任何对象)
两种解决方案:
1. 保持turnToString为静态方法(推荐,适合工具类方法)
就是现在的写法,在方法前加static:
public static String turnToString(int [] arr) { ... }2. 创建类的实例后调用非静态方法
如果确实需要非静态方法,可以这样修改:
public class Demo01 {
public static void main(String[] args) {
int[] arrInt = {1,2,3,4,5};
// 创建当前类的实例
Demo01 demo = new Demo01();
// 通过实例调用非静态方法
String result = demo.turnToString(arrInt);
System.out.println(result);
}
// 非静态方法(去掉static)
public String turnToString(int [] arr) {
// 方法体不变...
if(arr==null){
return "";
}
if(arr.length==0){
return "";
}
String result="[";
for (int i = 0; i < arr.length ; i++) {
result=result+arr[i];
}
return result+"]";
}
}总结:
- 静态方法(如
main)中,只能直接调用其他静态方法 - 调用非静态方法必须通过类的实例(对象)
- 像
turnToString这种工具性质的方法,通常定义为静态方法更合适,方便直接调用
可变参数的定义
定义方法时,有时方法的同一类型参数个数不固定,采用重载的方法,需要书写很多个情况,而且如果是n个相同类型参数就要写n个方法,比较麻烦,这时可以采用可变参数来处理
可变参数的本质是一个指定类型的数组,传入对应类型的参数时,会自动把参数放入数组中,不必提前定义好数组,然后再把数据传入数组中。
int [] arr = {1,2,3,4,5,6};
getSum(arr);
public static int getSum(int [] arr){
int sum = 0;
for(int i: arr){
sum =sum +i;
}
return sum;
}
// 采用可变参数之后,不必再单独定义数组,直接填入即可
getSum(1,2,3,4,5,6);
public static int getSum(int...args){
int sum = 0;
for(int i: arr){
sum =sum +i;
}
return sum;
}注意
- 形参列表中可变参数只能有一个
- 可变参数必须放在形参列表的最后面
Java中的类和对象
类的定义
指的是对于一类事物的统称,例如手机就是一个类,电脑是一个类,犬科动物属于一个类。
或者指我们开发过程中对一些类似功能,所定义的一个集合,例如对数据的增,删,改,查可以作为对数据的处理集合,定义一个类,把这些功能封装到类里面(就是写到同一个类里面)。
属性的定义
同一个类中的事物具有的特性称为类中的属性,例如手机有型号,尺寸大小,处理器,屏幕。
对象的定义
类中的一个单独个体,类只是一个定义性的概念,相当于定义了一个模式,里面指定了这个类包含了有哪几种格式的数据,以及可以实现什么样的功能。定义好类之后才会有对象。
例如手机是一个类,它规定了手机有型号,尺寸大小,处理器,屏幕,他的功能是打电话,发短信,打游戏。
当我们需要使用类中的一些功能或者属性时,就需要先创建一个对象,并指定这个对象是属于哪个类,然后才能调用我们想要的数据或者是特定的类中的功能。
类和对象的创建
- 类的修饰符(public ,private,protecetd) class 类名
public class 类名 {
1、成员变量(代表属性,一般是名词)
2、成员方法(代表行为,一般是动词)
3、构造器(后面学习)
4,代码块(后面学习)
5、内部类(后面学习)
}- 类名 对象名 = new 类名 ( ) ;
Phone p = new Phone();类的分类
工具类
帮助我们做一些事情的,但是不描述任何事物的类,不是用来描述一类事物的,而是帮我们做一些事情的类。
- 类名见名知意
- 私有化构造方法(这样外界无法创建该类的对象,因为创建工具类的对象是没有意义的,方法为静态方法,类名.静态方法即可调用)
public class Student{
private Student(){
...
}
}测试类
用来检查其他类是否书写正确,带有main方法的类,是程序的入口
Javabean类
用来描述一类事物的类。比如,Student,Teacher,Dog,Cat 等
权限修饰符
私有private
- 是一个权限修饰符
- 可以修饰成员 (成员变量和成员方法)
- 被
private修饰的成员只能在本类中才能访问 - 避免数据暴漏在外,被随意篡改
注意
由于成员变量被private修饰后只能在本类中访问,所以后续若要用到这个private属性的变量,就需要对外界提供一个接口(方法),把方法定义为public通过这个公用方法来实现按调用这个private变量。
public class GirlFriend {
private int age;
//age属性为private,只能在本类中访问,setAge为向外界提供的接口方法,让外界也能正常调用age,
//当外界调用setAge方法时,传入参数的同时,通过GrilFriend定义的对象调用这个setAge,也会把这个调用对象的地址值传递给this指针,方法中的age其实为this.age,this地址为调用方法的对象,所以也相当于在本类中调用age
public void setAge(int a){
if(a>=18&&a<=50){
age=a;
}
else{
System.out.println("数据不符合规则!");
}
}
//获取age的值
public int getAge(){
return age;
}
}总结
age被声明为private,确实只能在GirlFriend类内部直接访问和修改setAge是public方法,作为外部访问的接口,它在类内部操作age属性- 由于
setAge是类的成员方法,当外部通过对象调用它时,方法内部的age本质上是this.age(this指向当前调用方法的对象) - 这种在类内部修改私有属性的操作是完全合法的
- 由于
这样设计的好处是:
- 保持了属性的封装性(不允许外部直接修改)
- 通过方法可以对传入的值进行验证(如代码中的年龄范围校验)
- 控制了属性的修改逻辑,确保数据合法性
简单说就是:setAge作为类的内部方法,有权访问私有属性age,外部通过调用这个公共方法间接修改私有属性,同时还能进行数据校验。
就近原则
类中会有成员变量以及局部变量,当在类中方法中定义了与成员变量名称相同的局部变量时,在调用的时候一般是调用局部变量,因为局部变量离调用语句比较近,称为就近原则
若要调用成员变量,在前面加this即可
public class GirlFriend {
private int age;
public voidd method(){
int age= 10;
System.out.println(age); //此处调用的是 age = 10,局部变量离调用语句较近
System.out.println(this.age); //指定了调用成员变量
}
}构造方法
构造方法的格式
public class Student {
修饰符类名(参数){
方法体;
}
}特点
- 方法名与类名相同,大小写也要一致
- 没有返回值类型,连void都没有
- 没有具体的返回值(不能由retrun带回结果数据)
执行时机
- 建对象的时候由虚拟机调用,不能手动调用构造方法
- 每创建一次对象,就会调用一次构造方法
- 如果一个类中没有构造方法,虚拟机会自动的给你添加一个默认的空参构造
public class Student {
private String name;
private int age;
public Student(){ //空参构造
......
}
public Student(String name,int age){ //带参构造
.....
}
}作用
定义对象的时候,对对象进行数据初始化,可以在定义对象时直接指定对象中的成员变量的数据,不需在单独调用各个方法来赋值给private或者public类型的变量。
this 关键字
this的本质:代表方法调用者的地址值
this的作用:区分局部变量和成员变量
当对象调用对象自身中的方法时,this默认的就是指向该方法调用者的地址值,此时this.变量名就代表该调用者内部的属性值(也可以叫变量)
static 静态关键字
static表示静态,是Java中的一个修饰符,可以修饰成员方法,成员变量
被
static修饰的成员变量,叫做静态变量特点:
被该类所有对象共享,静态变量是随着类的加载而加载的,优先于对象出现的,不属于对象,属于类。
调用方式:
类名调用(推荐),对象名调用
Student.静态变量(static修饰)
Student student=new Student();
student.静态变量(static修饰)- 被
static修饰的成员方法,叫做静态方法特点:多用在测试类和工具类中,Javabean类中很少会用调用方式:类名调用 (推荐),对象名调用
static 静态内存区
示例代码如下:
public class Student {
String name;
int age;
static String teacherName;
public void show(){
System.out.println(name+"..."+age+"..."+TeacherName);
}
}
public class Test1static {
public static void main(String[] args){
Student.teacherName ="阿玮老师";
Student s1 = new Student();
s1.name ="张三";
s1.age=23;
s1.show();
Student s2 = new Student();
s2.show();
}当程序运行时,main 方法先进栈,然后运行第一行Student.teacherName ="阿玮老师";代码调用了Student类以及类中的静态变量,此时Student的字节码文件加载到方法区中,然后在堆中创建了一块静态区(存储静态变量位置JDK8之前是在方法区中),静态变量是随着类的加载而加载的,优先于对象出现的,同时对静态变量进行初始化,初始值为null后续再赋值为阿伟老师。所有的同类对象所调用的静态变量的地址是相同的也就是同一个静态变量。
static 注意事项
- 静态方法只能访问静态变量和静态方法 ( 由于静态方法中没有
this进行调用的话,不能指定是哪个对象的成员方法或者变量 ) - 非静态方法可以访问静态变量或者静态方法,也可以访问非静态的成员变量和非静态的成员方法
- 静态方法中是没有
this关键字
静态方法不能访问非静态的内容
内存角度分析
//定义学生类如下
public class Student {
String name;
static String teacherName;
//定义静态方法如下
public static void method(){
System.out.println(name+teacherName);
show();
}
//非静态方法如下
public void show(){
System.out.println(name + "..."+TeacherName);
}
}
//主程序代码如下
public class TestiStatic{
public staticvoid main(String[] args){
Student.teacherName="阿玮老师";
/*
第一行代码运行:首先main方法进栈,然后方法区中先加载Student.class字节码文件,同时类中的一些成员属性以及成员方法都在里面,因为调用了静态变量,所以在堆内存里面开辟静态区存入静态变量teacherName,然后赋值为阿伟老师。
*/
Student.method();
/*
当调用静态方法时,静态方法method进栈,方法中调用了非静态方法show(这是错误的实际静态方法无法调用),假设能调用,此时show方法进栈,通过类名Student来在show方法中打印teacherName时会去堆中Student的静态区中寻找变量,但是由于没有创建对象也就没有成员属性的值(本例中是name),且静态方法中并没有this关键字,成员方法一般是在调用时,传入调用者的地址值给this,然后依据this来寻找成员属性(也就是非静态变量,即name),所以找不到对应的name。
*/
}
}总结
- 静态方法中,只能访问静态。
- 非静态方法可以访问所有。
- 静态方法中没有
this关键字 - 静态方法不能调用实例变量
认识 main 方法
public class Helloworld {
public static void main(String[] args) { //[]:数组,String:数据类型,args:数组名
System.out.println("Helloworld");
}
}- public : 被JVM调用,访问权限足够大
- static : 被JVM调用,不用创建对象,直接类名访问因为 main 方法是静态的,所以测试类中其他方法也需要是静态的。
- void : 被JVM调用,不需要给JVM返回值
- main : 一个通用的名称,虽然不是关键字,但是被IVM识别
- String[ ] args : 以前用于接收键盘录入数据的,现在没用
标准的JavaBean类
- 类名需要见名知意
- 成员变量使用private修饰
- 提供至少两个构造方法
无参构造方法
带全部参数的构造方法 - 成员方法
提供每一个成员变量对应的setXxx()/getXxx()
如果还有其他行为,也需要写上
Java中对象运行时的内存过程
首先Java的运行都是在虚拟机中运行的,虚拟机运行时会占用一部分内存空间,这一块内存中分别分为,本地方法栈,寄存器,栈,方法区,堆。
当我们要运行一个类的时候,该类的字节码文件会先存储到方法区(JDK8之后是元空间)****当中,JDK7之前方法去与堆是连到一起的,后续JDK8之后就进行了拆分,取消方法区,新增元空间。把****原来方法区的多种功能进行拆分有的功能放到了堆中,有的功能放到元空间中。
运行代码时,对代码进行编译后产生字节码文件(.class),然后JVM(虚拟机)开始运行,把字节码文件加载到元空间中,然后运行main方法,把方法加载到栈空间中(方法的运行以及变量的创建都是占用栈中的内存空间),代码中new出来的变量都占用堆当中的空间,并且返回该变量在堆中的地址值。
当调用对象中的成员方法时,通常是 对象名.方法名 的形式,此时程序依据对象变量存储的地址值,找到该变量在堆当中的存储信息,然后通过该内存中存储的成员方法的地址值来进行调用成员方法,此时成员方法进栈。
对象的创建过程
Student s = new Student();- 加载class文件
- 申明局部变量
- 在堆内存中开辟一个空间
- 默认初始化 (在未对对象中的变量赋值之前,虚拟机会先给变量赋值为空)
- 显示初始化 ()
- 构造方法初始化 (依据对象中的构造方法来进行初始化)
- 将堆内存中的地址值赋值给左边的局部变量
具体如下:
1. 对象创建与内存分配
当通过 new 关键字创建对象时(如 Student s = new Student();):
- 堆内存:会分配一块空间存储对象的成员变量(如学生的姓名、年龄),同时会记录该对象所属类中成员方法的入口地址(方法本身存储在方法区,堆中仅保留引用)。
- 栈内存:会创建一个对象变量(如
s),存储的是堆中该对象的地址值(类似 “指针”,指向堆中的具体对象)。
2. 调用成员方法的步骤
当执行 对象名.方法名()(如 s.study();)时:
- 通过对象变量找对象:程序读取栈中对象变量(
s)存储的地址,定位到堆中对应的对象。 - 获取方法地址:从堆中对象的信息里,找到该成员方法(
study())在方法区的入口地址。 - 方法进栈执行
- 方法区的方法本身不会移动,但会在栈内存中创建一个 “栈帧”(包含方法的参数、局部变量等)。
- 程序按照栈帧中的指令执行方法,执行完毕后栈帧出栈,释放内存。
关键说明
- 成员方法的共享性:同一类的所有对象共享方法区中的成员方法(不会为每个对象复制一份方法),堆中仅记录方法的引用地址,节省内存。
- 栈的特性:方法调用遵循 “先进后出” 原则,例如
A()调用B()时,A()的栈帧先入栈,B()的栈帧再入栈,B()执行完后先出栈,最后A()出栈。
通过这种地址引用的方式,Java 等面向对象语言实现了对象与方法的动态关联,保证了代码的灵活性和内存的高效利用。
对象的生命周期
当程序中没有指向该对象地址的对象变量时,堆中创建的对象便会成为‘垃圾’然后自动消失,处在方法中的局部变量也会在该方法执行完毕后自动消失。
字符串
String是Java定义好的一个类。定义在 java.lang 包中所以使用的时候不需要导包
java.lang.String 类代表字符串,Java 程序中的所有字符串文字(例如“abc”)都为此类的对象
注意:字符串不可变,它们的值在创建后不能被更改
String 的创建方式
直接赋值
String name="abc";new 一个
创建一个String类的对象,采用 String name=new String();的方式,另外也可以使用规定好的 String 类的构造方法。
| 构造方法 | 说明 |
|---|---|
| public String( ) | 创建空白字符串,不含任何内容 |
| public String( String original ) | 根据传入的字符串,创建字符串对象 |
| public String( char[ ] chs ) | 根据字符数组,创建字符串对象 |
| public String( byte[ ] chs ) | 根据字节数组,创建字符串对象 |
示例如下:
public static void main(String[] args) {
//创建字符串的方式
//直接创建字符串并进行赋值
String name = "<这是一个直接创建的字符串>";
System.out.println(name);
//采用构造方法创建一个空的字符串
String emptyName=new String();
System.out.println("这是一个空的字符串:"+emptyName+"<---字符串在前面");
//采用传参的方式new一个新的字符串
String newName=new String(name);
System.out.println("这是传参数new的字符串:"+newName);
/*
传递一个字符数组,依据字符数组创建新的字符串
当我们想要修改字符串内容时可以这样,依据传递进去的字符数组的索引,来进行修改
由于java中字符串创建后不能改变,要进行修改可以先转换为字符数组,依据数组索引修改
修改完之后再转变回字符串
*/
char[] chars={'y','j','c'};
String charString=new String(chars);
System.out.println("这是依据传递来的字符数组创建的字符串:"+charString);
/*
依据传递的字节数组的数据创建字符串
依据ASII码表来找到对应的数据
应用场景:以后在网络当中传输的数据其实都是字节信息
我们一般要把字节信息进行转换,转成字符串,此时就要用到这个构造了
*/
byte[] bytes={65,97};
String byteString=new String(bytes);
System.out.println("这是依据传进来的字节数组创建的字符串:"+byteString);
}注意
在 Java 中,无论是直接赋值的字符串(字符串常量)还是通过new关键字创建的字符串,它们本身都是不可变的
这意味着字符串对象一旦创建,其内容(字符序列)就不能被修改。任何看似修改字符串的操作(如concat()、substring()等),实际上都会创建一个新的字符串对象,而原字符串对象的内容保持不变。
例如:
String result="abcdwqcba"会创建一个新的字符串对象,并让result变量指向它- 执行
result = result.replace("wqcba","YJC")时,由于字符串不可变,replace()方法会创建一个新的字符串对象(替换后的结果),然后让result变量改变指向,指向这个新对象 - 原来的那个字符串对象(
sb.toString()的结果)因为不再有任何变量引用它,就会成为垃圾,等待垃圾回收器回收
整个过程中,没有任何字符串对象被 "修改",只是变量的引用关系发生了变化。这正是字符串不可变性的体现 —— 所有对字符串的 "修改" 操作都会产生新的字符串对象,而不是改变原有对象的内容
创建字符串的两种区别
两者的区别主要在于存储位置和内存复用机制:
- 直接赋值的字符串(如
String s = "abc";):会被存储在字符串常量池中,相同内容的字符串会复用同一个对象,以节省内存。 - 通过
new创建的字符串(如String s = new String("abc");):会在堆内存中创建一个新对象,即使内容与常量池中的字符串相同,也会生成独立的对象(可以通过intern()方法将其加入常量池实现复用)。
但无论哪种创建方式,字符串对象本身的不可变性是一致的。这种设计主要是为了保证字符串的线程安全、实现常量池复用以及提高哈希表等场景的效率。
字符串的存储
直接赋值的字符串
直接赋值方式得来的字符串是存储在串池当中,JDK7之前这个串池是在方法区当中的,JDK7开始就划分到堆当中了
public class StringDemo{
public staticvoid main(String[] args){
String s1 ="abc";
String s2 ="abc";
}
}当使用双引号直接赋值时,系统会检查该字符串在串池中是否存在。
- 不存在:创建新的,并把字符串在串池当中的地址赋值给字符串变量。
- 存在:复用,把已经存在的字符串的地址赋值给字符串变量。
new 方式得到的字符串
字符串得到之后,存储在堆当中,然后把字符串的地址值赋值给字符串变量,每 new 出一个字符串变量,就是一个新地址,不会出现同直接赋值的字符串那种复用的情况
字符串的比较
完全一样
boolean equals方法(要比较的字符串) 完全一样结果才是true,否则为false
public static void main(String[] args) {
//比较字符串的两种方式:
//完全一样才会true,否则为false
String school="南阳师范学院";
String college=new String("南阳师范学院");
boolean flage=school.equals(college);
System.out.println(flage);
}忽略大小写比较
boolean equalslgnoreCase(要比较的字符串) 忽略大小写的比较
//忽略大小写的比较方式
String s1="ABCDEFG";
String s2="abcdefg";
System.out.println(s1.equalsIgnoreCase(s2));字符串遍历
依据索引返回字符
public char charAt(int index): 根据索引返回字符
获取字符串长度
public int length():返回此字符串的长度
数组的长度:数组名.length
字符串的长度:字符串对象.length()
字符串截取
String substring(int beginlndex, int endlndex)截取注意点:包头不包尾,包左不包右只有返回值才是截取的小串String substring(int beginlndex): 从指定位置开始 截取到末尾,包含指定位置
字符串替换
String replace(旧值,新值)替换:只有返回值才是替换之后的结果
StringBuild 工具
StringBuilder可以看成是一个容器,创建之后里面的内容是可变的
作用:提高字符串的操作效率
例如原本的字符串拼接方式,由于字符串不可变,所以任何对字符串进行操作的方法都是创建了一个新的字符串对象,利用StringBuild可以创建一个可变的字符串,修改之后是对原来的字符串的值进行了修改。
由于是用StringBuild来进行操作的,所以在使用完毕后要把结果类型转换为String类型
StringBuild 的构造方法
| 方法名 | 说明 |
|---|---|
| public StringBuilder() | 创建一个空白可变字符串对象,不含有任何内容 |
| public StringBuilder ( String str ) | 根据字符串的内容,来创建可变字符串对象 |
StringBuild的常用方法
| 方法名 | 说明 |
|---|---|
| public StringBuilder append (任意类型) | 添加数据,并返回对象本身 |
| public StringBuilder reverse() | 反转容器中的内容 |
| public int length() | 返回长度(字符出现的个数) |
| public String toString () | 通过toString就可以实现把StringBuilder转换为String |
StringJoiner 工具
StringJoiner跟StringBuilder一样,也可以看成是一个容器,创建之后里面的内容是可变的。
作用:提高字符串的拼接操作效率,而且代码编写特别简洁,JDK8之后出现的
StringJoiner 的构造方法
| 方法名 | 说明 |
|---|---|
| public StringJoiner (间隔符号) | 创建一个StringJoiner对象,指定拼接时的间隔符号 |
| publicStringJoiner(间隔符号,开始符号,结束符号) | 创建一个StringJoiner对象,指定拼接时的间隔符号、开始符号、结束符号 |
StringJoiner 的成员方法
| 方法名 | 说明 |
|---|---|
| public StringJoiner add (添加的内容) | 添加数据,并返回对象本身 |
| public int length() | 返回长度 ( 字符出现的个数 ,包括前后缀及间隔字符,还有指定时他们时包含的空格) |
| public String toString() | 返回一个字符串( 该字符串就是拼接之后的结果 ) |
字符串原理
字符串存储的内存原理
- 直接赋值会复用字符串常量池中的
- new出来不会复用,而是开辟一个新的空间
== 号比较的到底是什么?
- 基本数据类型比较数据值
- 引用数据类型比较地址值
字符串拼接的底层原理
- 如果没有变量参与,都是字符串直接相加,编译之后就是拼接之后的结果,会复用串池中的字符串拼接的时候没有变量,都是字符串。触发字符串的优化机制。在编译的时候就已经是最终的结果了,然后以直接赋值的方式来对字符串赋值,此时若串池中已经有相同的字符串,则采用字符串复用的机制。
public class Test {
public static void main(String[] args){
String s = "a"+ "b"+"c";
System.out.println(s);
}
}
//由于全是字符直接拼接,没有变量参与,所以在编译成字节码文件时就已经得出最终结果,并以直接赋值的形式来对字符串变量 s 赋值,赋值代码就相当于:String s = "abc";- 如果有变量参与,每一行拼接的代码,都会在内存中创建新的字符串,浪费内存在拼接的时候有变量,
JDK8以前底层会使用StringBuilder
public class Test {
public static void main(String[] args){
String s1 ="a";
String s2 = s1 + "b";
String s3 = s2 +"c";
System.out.println(s3);
}
}如上代码所示,s1为直接赋值的字符串变量,存储在堆中的串池中。
当对 s2,s3 进行赋值时:
JDK8 之前是采用创建一个(new)StringBuild 的对象,然后把 s1 和 "b" 放到创建的 StringBuild 对象中,利用 StringBuild 的字符串拼接功能实现字符串的拼接,并在拼接完成后,再用其 toString 功能(本质也是 new 一个新的String 对象)把结果类型从 StringBuild 转换为 String 类型的对象。(每拼接一个就需要创建(new)两个对象)
JDK8 开始是先预估拼接的字符串数量,然后依据预估结果创建一个总的字符数组(因为底层还是用的 StringBuild的append功能,StringBuilder的内部实现是使用字符数组(char [ ]) 来存储字符串内容的。
当进行字符串拼接时,StringBuilder会先预估所需的字符总数,然后创建一个足够容纳这些字符的字符数组。如果后续拼接导致字符数量超过数组容量,会创建新的更大的字符数组,并将原有内容复制过去。
这种基于字符数组的实现相比早期版本的字符串拼接方式(如直接创建新的String对象),大大减少了内存分配和复制操作,显著提升了字符串拼接的性能。),最后创建一个总的 StringBuild 对象,然后利用 StringBuild 完成拼接后,再用 toString 转换为 String 类型的对象。但是每次预估都需要时间,并且也会造成创建多个对象的问题。(每一行拼接代码就需要预估一次)
总结每次字符串拼接的时候有变量参与:就会在内存中创建了很多对象浪费空间,时间也非常慢如果很多字符串变量拼接,不要直接 + 。在底层会创建多个对象,浪费时间,浪费性能,直接用StringBuild对象进行拼接。
StringBuilder 提高效率原理图
所有要拼接的内容都会往StringBuilder中放,不会创建很多无用的空间,节约内存
面试例题
问题01
下列代码的运行结果是?
public class Test3 {
public static void main(String[] args){
String s1 ="abc"; //记录串池中的地址值
String s2 ="ab";
String s3 = s2 +"c"; //新new出来的对象
System.out.println(s1 == s3); // false
}
}字符串拼接的时候,如果有变量:
JDK8以前:系统底层会自动创建一个StringBuilder对象,然后再调用其append方法完成拼接。拼接后,再调用其toString方法转换为String类型,而toString方法的底层是直接new了一个字符串对象。
JDK8版本:系统会预估要字符串拼接之后的总大小,把要拼接的内容都放在字符数组中,此时也是产生一个新的字符串。
问题02
下列代码的运行结果是?
public class Test4{
public staticvoid main(String[] args){
String s1="abc"; //记录串池中的地址值
String s2 = "a" +"b"+"c"; //复用串池中的字符串
System.out.println(s1 == s2); // true
}
}拼接代码中没有变量参与,在编译的时候,就会将"a"+"b"+"c"拼接为"abc"
StringBuilder 源码分析
- 默认创建一个长度为16的字符数组
- 添加的内容长度小于16,直接存
- 添加的内容大于16会扩容(原来的容量*2+2)
- 如果扩容之后还不够,以实际长度为准
在 Java 中,当你通过 new StringBuilder() 创建对象时:
- 其内部维护的是字符数组(char []),而非字节数组或字符串数组。这是因为
StringBuilder本质上是对字符序列的操作容器,字符数组能更直接地存储和处理 Unicode 字符。 - 初始容量(即字符数组的长度)默认是 16。这个初始长度在
StringBuilder的无参构造方法中定义,源码大致如下:
public StringBuilder() {
super(16); // 调用父类 AbstractStringBuilder 的构造方法,指定初始容量为16
}当拼接的字符总数超过当前数组容量时,StringBuilder 会自动扩容(通常是原容量的 2 倍 + 2),并将原有字符复制到新数组中,以保证后续操作的效率。如果预先知道字符串大致长度,也可以通过 new StringBuilder(int capacity) 指定初始容量,减少扩容次数,进一步优化性能。
集合
集合与数组同为存储数据的工具,
数组:长度固定,基本或者引用数据类型都可以存储
集合:长度随数据变化,只能存储引用数据类型,若要存基本数据类型,需要先把他们转换为对应包装类。
但是集合具有以下特点:
- 集合的长度会根据其中数据进行自动增减(数组长度固定),增加或者修改集合中数据时不需单独设置相关代码
- 集合中存储的是引用数据类型,若要存基本数据类型,则先把基本数据类型先转换为对应包装类
- 在打印集合时,直接打印集合名字打印的不是地址值,而是集合中的所有数据,数据用
[ ]括起来
集合的创建
创建集合后,需要在集合后面的尖括号<>里面指定集合中存储的数据类型,尖括号中的内容称作集合的泛型
//泛型:类似于创建数组然后指定数组的存储数据类型
public class ArrayListDemo1 {
public static void main(String[] args) {
//1.创建集合的对象
//泛型:限定集合中存储数据的类型
//ArrayList<String> 1ist = new ArrayList<String>();
//JDK7: 开始对创建集合的方式进行了简化,后续只需在前面指定数据类型即可,后面的尖括号不能省略
ArrayList<String> list = new ArrayList<>();
}
}需要注意的是,当用集合来存储基本数据类型时,需要把基本数据类型转换为对应的包装类(基本数据类型的引用(对象)形式)。
集合的打印
Java中对集合的打印做了处理,当我们直接打印集合时,不是像数组那样打印的是地址值,而是打印集合中的数据,并且把集合中的数据用[]包裹起来
//此时我们创建的是ArrayList的对象,而ArrayList是java已经写好的一个类
//这个类在底层做了一些处理
//打印对象不是地址值,而是集合中存储数据内容
//在展示的时候会拿[门把所有的数据进行包裹
ArrayList<String> list = new ArrayList<>();
System.out.println(list);集合的常用方法:
| 方法 | 说明 |
|---|---|
| boolean add (E e) | 添加元素,返回值表示是否添加成功(注意该方法返回值一直都是true) |
| boolean remove (E e) | 删除指定元素,返回值表示是否删除成功 |
| E remove (int index) | 删除指定索引的元素,返回被删除元素 |
| E set (int index,E e) | 修改指定索引下的元素,返回原来的元素 |
| E get (int index) | 获取指定索引的元素,返回的是地址值 |
| int size () | 集合的长度,也就是集合中元素的个数 |
注意:
add方法返回值永远为True,ArryList属于List单列集合,集合中数据可以重复。
泛型
用于限定存入集合的数据类型。
- 泛型中不能写基本数据类型
- 指定泛型的具体类型后,传递数据时,可以传入该类类型或者其子类类型
- 如果不写泛型,类型默认是object
泛型擦除
java中即便指定了泛型,也只是在把数据放入集合时对其进行检查,符合泛型就放入,不符合则丢弃,实际存入之后,编译为字节码文件时,元素类型仍为 Object 类型,取出元素时,会自动进行Object类型到指定的泛型类型的转换,以保证输出的数据为指定的泛型的格式,这就是泛型擦除。
泛型类
当一个类中,有属性的类型不确定时,就可以定义一个有泛型的类,该属性的类型通过泛型来指定。
修饰符 class 类名<E>{
E a;
}此处E可以理解为变量,但是不是用来记录数据的,而是记录数据的类型,可以写成:T、E、K、V等,任意即可
泛型方法
方法中的参数不确定时,使用类名后面定义的泛型<E>
方案一:使用类上定义的泛型
public class MyArrayList <E> {
public boolean add(E e){
//参数类型不确定,使用泛型暂时填充
}
}方案二,在方法上定义属于方法自己的泛型
修饰符 <类型> 返回值类型 方法名(类型 变量名){
}
//定义属于方法自己的泛型,只能在方法中使用
public <T> void show(T t){
}此处E可以理解为变量,但是不是用来记录数据的,而是记录数据的类型,可以写成:T、E、K、V等,任意即可
传入了 users(列表),编译器怎么知道 T 是什么?
靠的是 Java 的 类型推断。编译器会看你传入的参数是什么类型。
- 如果你的
users变量声明是List<User>,那么编译器看到success(List<T> data)这个方法签名时,就会把T推断为User。 - 此时,整个方法返回的就是
Result<User>。
举个直观的例子:
// 假设你的 users 是这个
List<User> users = userService.findAll();
// 调用 success
Result<User> result = Result.success(users);
// 编译器心里想:你传的是 List<User>,那 T 就是 User,所以返回 Result<User>这个过程发生在编译期,不需要你手动写 Result.<User>success(users),Java 会自动帮你补全(当然你也可以显式写上,但通常没必要)。
问题2:为什么返回值 Result<T> 前面还要加一个 <T>?
关键在于分清“类的泛型”和“方法的泛型”。
class Result<T>这里的T是类的泛型,属于实例级别。它只有在创建对象时(比如new Result<User>())才会被确定。- 静态方法属于类本身,它不依赖于任何具体的对象实例。因此,静态方法不能直接使用类上定义的泛型
T(因为静态方法加载时,实例的泛型还没确定呢)。
所以,如果静态方法想使用泛型,必须自己声明自己的泛型。
语法拆解:public static <T> Result<T> success(List<T> data)
我们把这段代码分成三部分来看:
| 代码片段 | 含义 |
|---|---|
public static | 访问修饰符,静态方法 |
<T> | 声明:告诉编译器“我这个静态方法自己定义了一个泛型符号,叫 T”。(这个 T 和类头上的 Result<T> 不是同一个,只是恰好名字一样) |
Result<T> | 使用:返回类型。告诉编译器,我返回的 Result 对象里装的正是刚才声明的那个 T 类型。 |
(List<T> data) | 参数:入参也是刚才声明的那个 T 类型的列表。 |
如果去掉方法上的 <T> 会怎样?
// 错误示范
public static Result<T> success(List<T> data) { // 报错!
// ...
}编译器会直接报红:cannot find symbol 'T'(找不到符号 T)。因为如果不写 <T>,编译器认为这里的 T 是指类头上的 Result<T>,但静态方法访问不了类的泛型,所以直接报错。
总结
- 类型推断:编译器通过你传入的
List<User>自动识别出T=User。 - 方法上的
<T>:这是必须的,它是静态方法“自带的身份证”,告诉编译器“我也有自己的泛型,别拿我和类的泛型搞混”。
你可以把类的泛型 T 理解成“对象的属性”,把静态方法的 <T> 理解成“方法的局部变量”,虽然名字一样,但作用域完全不同!
静态方法的 T 和对象实例的 T 没关系,静态方法“不看”对象实例。
当你写 Result.success(users) 时,你还没有创建 Result 的任何对象实例。你调用的是类名 Result 上的静态方法,这时候内存里连个 Result 对象都没 new 出来呢。
既然没有对象实例,何来“对象实例传入自己的 T”呢?所以,静态方法推断泛型,靠的不是对象实例,而是方法调用时的上下文(也就是你传进去的参数,以及你把返回值赋值给谁)。
静态方法的 T 到底怎么来的?
关键原则:静态方法的泛型,完全由方法参数和返回值赋值决定,和类头上的 <T> 没有任何瓜葛。
我们对比两种情况:
情况 1:你传的是 List<User>
List<User> users = ...;
Result<User> result = Result.success(users);- 编译器看了一眼你传的参数
users,类型是List<User>。 - 它对照方法签名
public static <T> Result<T> success(List<T> data),发现参数data是List<T>,而你给的是List<User>。 - 结论:编译器把静态方法自己的那个
<T>推断为User。
情况 2:假设类上定义的是 Result<String>,但静态方法依然不看它
// 假设类定义是 class Result<String>(类T是String)
// 但静态方法我照样可以这么干:
List<Integer> numbers = ...;
Result<Integer> result = Result.success(numbers); // 完全合法!静态方法返回的是 Result<Integer>,它完全无视了类定义的 Result<String>。这足以证明,静态方法的泛型和对象实例的泛型是两个世界的东西。
- 类上的
T(实例级别):相当于一个模具。你拿这个模具去造东西(new Result<User>()),造出来的东西上面刻着“User”。 - 静态方法上的
<T>(方法级别):相当于一个独立的生产机器。它不依赖任何模具,它只看你丢进去的原料(参数)是什么,就能生产出对应型号的产品(返回值)。
它们俩恰好都叫“T”,只是个名字巧合。你完全可以给静态方法换个字母,比如写成 public static <E> Result<E> success(List<E> data),效果完全一样。编译器照样通过你传入的 List<User> 把 E 推断成 User。
总结
静态方法里的 <T> 是“自给自足”的,它从参数里猜出来你要什么,压根儿不关心类上声明的是什么 T。
泛型接口
接口中的某些类型不确定,使用泛型暂时填充
修饰符 interface 接口名<类型 > {
}
public interface List<E> {
}使用方式如下:
在实现类中直接给出具体类型
public class MyArrayList implements List<String>{
//重写接口中的抽象方法
......
}
//创建实现类对像时直接创建即可
MyArrayList list = new MyArrayList();在实现类中仍然使用泛型填充不确定的类型,在后续创建对象时再指定类型
//接口的实现类的泛型与接口一致,也就是延续接口泛型
public class MyArrayList<E> implements List<E>{
//重写接口中的方法
.....
}
//创建实现类对象的时候在进行指定泛型
MyArrayList<String> list = new MyArrayList<>();泛型的通配符
定义方法时,若不确定参数类型是什么,可以使用泛型来进行填充,但是这样填充的结果就是,任意类型都可以传入作为参数。
同时由于泛型不具有继承性,也就是若泛型处指定的是父类类型,那他的子类也无法使用
//泛型方法
public void method(ArrayList<E> list){
//此时参数传为任意类型都可以,不会报错,但是有时希望加上限定范围,就需要使用到通配符
}
//若泛型处指定的是父类
public void method(ArrayList<Ye> list){
}
//传入参数时,父类的子类zi仍不能传入,会报错。这时可以采用通配符的方式来进行限定传入的参数类型范围:
?也表示不确定的类型他可以进行类型的限定? extends E:表示可以传递E或者E所有的子类类型? superE:表示可以传递E或者E所有的父类类型
//泛型方法,修饰符后面写泛型<E>,参数位置也要写泛型<E>
public<E> void method(ArrayList<E> list){
//此时参数传为任意类型都可以,不会报错,但是有时希望加上限定范围,就需要使用到通配符
}
//单独写通配符 ?与写 E 作用是一样的,只不过不需要在修饰符后面写泛型
public void method(ArrayList<?> list){
}
// ? extends E:表示可以传递E或者E所有的子类类型
public void method(ArrayList<? extends E> list){
}
// ? super E:表示可以传递E或者E所有的父类类型
public void method(ArrayList<? super E> list){
}Collection集合(单列集合)
添加数据时,一个一个的添加数据
List系列集合:
添加的元素是有序(打印时数据是依据输入的数据来排列的)、可重复、有索引
ArryList,LinkedList,Vector都是属于List
Set系列集合
:添加的元素是无序(打印时,数据不是依据输入时的顺序来的)、不重复、无索引
HashSet,TreeSet都属于Set
LinkedHashSet属于HashSet
集合的删除方法remove
细节1:
因为collection里面定义的是共性的方法,所以此时不能通过索引进行删除。只能通过元素的对象进行删除。
细节2:方法会有一个布尔类型的返回值,删除成功返回true,删除失败返回false
如果要删除的元素不存在,就会删除失败。
集合判断元素是否包含
细节:底层是依赖equals方法进行判断是否存在的。
所以,如果集合中存储的是自定义对象(自定义对象不重写equals方法,默认使用的是父类Object的equals方法,只比较地址值是否一样,我们需要的是比较属性值即可,需要进行重写,String字符串也属于自定义对象,但是java已经写好的重写的equals方法),也想通过contains方法来判断是否包含,那么在javabean类中,一定要重写equals方法。
集合添加方法
细节1:如果我们要往List系列集合中添加数据,那么方法永远返回true,因为List系列的是允许元素重复的。
细节2:如果我们要往Set系列集合中添加数据,如果当前要添加元素不存在,方法返回true,表示添加成功。
- 如果当前要添加的元素已经存在,方法返回false,表示添加失败。
- 因为Set系列的集合不允许重复。
集合的遍历(List和Set通用的遍历)
原本的for循环是依据索引进行遍历的,现在的集合(Collection)中,List集合是有索引的,Set集合是没有索引的,就无法使用原本for循环的方式遍历,通用的遍历集合的方式为:采用迭代器,lambda表达式,增强for循环的方式进行遍历
迭代器遍历方式
选代器在Java中的类是lterator,迭代器是集合专用的遍历方式。选代器在遍历集合的时候是不依赖索引的
Collection集合获取迭代器
| 方法 | 说明 |
|---|---|
Iterator<E> iterator() | 返回迭代器对象,默认指向当前集合的0索引 |
boolean hasNext() | 判断当前位置是否有元素,有元素返回true,没有元素返回false |
E next() | 获取当前位置的元素,并将迭代器对象移向下一个位置。 |
选代器遍历相关的三个方法:Iterator<E>iterator():获取一个选代器对象boolean hasNext():判断当前指向的位置是否有元素E next():获取当前指向的元素并移动指针
//1.创建集合并添加元素
Collection<String> coll = new ArrayList<>();
col1.add("aaa");
col1.add("bbb");
col1.add("ccc");
col1.add("ddd");
//2.获取选代器对象
//选代器就好比是一个箭头,默认指向集合的0索引处
Iterator<String> it = col1.iterator();
//3.利用循环不断的去获取集合中的每一个元素
while(it.hasNext() ){
//4.next方法的两件事情:获取元素并移动指针
String str = it.next();
System.out.println(str);
}注意
- 报错NoSuchElementException
- 迭代器遍历完毕,指针不会复位,若想重新遍历,重新获取迭代器对象
- 循环中只能用一次next方法
- 迭代器遍历时,不能用集合的方法进行增加或者删除
增强for遍历
- 增强for的底层就是迭代器,为了简化迭代器的代码书写的。
- 它是JDk5之后出现的,其内部原理就是一个Iterator迭代器
- 所有的单列集合和数组才能用增强for进行遍历。
/*增强for格式:
for(数据类型变量名:集合/数组){
快速生成方式:
集合的名字+for回车
*/
//1.创建集合并添加元素
Collection<String> coll = new ArrayList<>();
col1.add("zhangsan");
coll.add("lisi");
coll.add("wangwu");
//2.利用增强for进行遍历
//注意点:
//s其实就是一个第三方变量,在循环的过程中依次表示集合中的每一个数据
for(String s:coll ){
System.out.println(s);
}注意
修改增强for中的变量,不会改变集合中原本0的数据。
for(String s:list ){
s = "qqq"
}Lambda表达式遍历
得益于JDK8开始的新技术Lambda表达式,提供了一种更简单、更直接的遍历集合的方式
| 方法 | 说明 |
|---|---|
default void forEach(Consumer<? super T> action) | 结合lambda遍历集合 |
//lambda表达式遍历:
default void forEach(Consumer<? super T> action):
//1.创建集合并添加元素
Collection<String> coll = new ArrayList<>();
col1.add("zhangsan");
col1.add("lisi");
co11.add("wangwu");
//2.利用匿名内部类的形式
//底层原理:
//其实也会自已遍历集合,依次得到每一个元素
//把得到的每一个元素,传递给下面的accept方法
//s依次表示集合中的每一个数据
coll.forEach(new Consumer<String>(){
@override
public void accept(String s){
System.out.println(s);
}
});
coll.forEach(s->System.out.println(s));Collections 集合的工具类
java.util.collections:是集合工具类- 作用:
Collections不是集合,而是集合的工具类。
常用API:
| 方法名称 | 说明 |
|---|---|
public static <T> boolean addAll(Collection<T> c, T ... elements) | 批量添加元素 |
public static void shuffle(List<?> list) | 打乱List集合的元素的顺序 |
结论
Collection是单列集合的顶层接口,所有方法被List和Set系列集合共享
常见成员方法:
add、clear、remove、contains、isEmpty、size
三种通用的遍历方式:
- 迭代器:在遍历的过程中需要删除元素,请使用迭代器。
- 增强for、Lambda:
List 集合
- 有序:有存和取的元素顺序一致
- 有索引:可以通过索引操作元素
- 可重复:存储的元素可以重复
- Collection的方法List都继承了
因为有索引,所以多了一些依据索引的操作方法。
| 方法 | 说明 |
|---|---|
| void add(int index,E element) | 在此集合中的指定位置插入指定的元素 |
| E remove(int index) | 删除指定索引处的元素,返回被删除的元素 |
| E set(int index,E element) | 修改指定索引处的元素,返回被修改的元素 |
| E get(int index) | 返回指定索引处的元素 |
ArryList 集合
- 利用空参创建的集合,在底层创建一个默认长度为0的数组(名称为
elementData) - 添加第一个元素时,底层会创建一个新的长度为10的数组
存满时,会扩容1.5倍 - 如果一次添加多个元素,1.5倍还放不下,则新创建数组的长度以实际为准
LinkedList 集合
底层数据结构是双链表(双向链表),查询慢,增删快,但是如果操作的是首尾元素,速度也是极快的,所以多了很多首尾操作的特有API。
| 特有方法 | 说明 |
|---|---|
| public void addFirst(E e) | 在该列表开头插入指定的元素 |
| public void addLast(E e) | 将指定的元素追加到此列表的末尾 |
| public E getFirst() | 返回此列表中的第一个元素 |
| public E getLast() | 返回此列表中的最后一个元素 |
| public E removeFirst() | 从此列表中删除并返回第一个元素 |
| public E removeLast() | 从此列表中删除并返回最后一个元素 |
Set集合
无序:存取顺序不一致
不重复:可以去除重复
无索引:没有带索引的方法,所以不能使用普通for循环遍历,也不能通过索引来获取元素
Set集合的实现类
HashSet:无序、不重复、无索引
LinkedHashSet:有序、不重复、无索引
TreeSet:可排序、不重复、无索引
Set接口中的方法上基本上与Collection的APl一致。
HashSet 集合
HashSet集合底层采取哈希表存储数据
哈希表是一种对于增删改查数据性能都较好的结构
哈希表组成: jdk8 之前 数组+链表 jdk8开始: 数组+链表+红黑树
哈希值
根据hashCode方法算出来的int类型的整数
该方法定义在Object类中,所有对象都可以调用,默认使用地址值进行计算
一般情况下,会重写hashCode方法,利用对象内部的属性值计算哈希值(idea中按alt+insert即可选择重写的方法)
对象的哈希值特点
如果没有重写hashcode方法,不同对象计算出的哈希值是不同的
如果已经重写hashcode方法,不同的对象只要属性值相同,计算出的哈希值就是一样的
在小部分情况下,不同的属性值或者不同的地址值计算出来的哈希值也有可能一样。(哈希碰撞)
HashSet集合的底层原理
- 先创建一个默认长度16,默认加载因为0.75的数组,数组名table
加载因子用作指定扩容的时机,当初始数组存储 16x0.75 个数据时,便进行扩容
- 根据元素的哈希值跟数组的长度计算出应存入的位置
计算方法:(数组长度 - 1) & 哈希值; 哈希值是利用 hashCode计算出来的
hashCode :Object中自带的方法,默认是利用地址值来计算,地址值对于我们存储数据来说并没有太大作用,存储数据时,set集合数据不能重复,所以存储数据时 hashCode 应进行重写,让他利用属性值进行计算。
equals:同样是Object中自带的方法,默认只比较地址是否相同,重写后会进行比较属性值是否一样,若是引用对象类型想要比较地址直接用 == 也可以
idea中按住 ctrl+insert 即可选择重写的方法
- 判断当前位置是否为null,如果是null直接存入
- 如果位置不为null,表示有元素,则调用equals方法比较属性值
- 一样:不存
不一样:存入数组,形成链表
JDK8以前:新元素存入数组,老元素挂在新元素下面
JDK8以后:新元素直接挂在老元素下面
注意
JDK8以后,当链表长度超过8,而且数组长度大于等于64时,自动转换为红黑树
如果集合中存储的是自定义对象,必须要重写hashcode和equals方法
LinkedHashSet 集合
- 有序、不重复、无索引。
- 这里的有序指的是保证存储和取出的元素顺序一致
- 原理:底层数据结构是依然哈希表,只是每个元素又额外的多了一个双链表的机制记录存储的顺序
存储机制与HashSet集合基本一致,只是在每一个元素中额外添加了指向下一个节点以及上一个节点的指针,
遍历元素时,不在默认从第0个节点开始遍历,而是依据指针从头节点开始遍历双向链表,所以有序。
使用情景
数据去重:HashSet
去重并要求存取有序: LinkedHashSet
TreeSet 集合
- 不重复、无索引、可排序
- 可排序:按照元素的默认规则(有小到大)排序。
- TreeSet集合底层是基于红黑树的数据结构实现排序的,增删改查性能都较好
普通二叉树:度(节点数)小于等于2即可,层数即为高度,
二叉查找树:小的数据存到左边,大的数据存到右边
平衡二叉树:任意节点的左右子树的高度差不大于1
红黑树是一种特殊的平衡二叉查找树,这里的平衡是指要满足特定的红黑规则,并不是平衡二叉树中的高度差规则
TreeSet 集合默认排序规则
- 对于数值类型:Integer,Double,默认按照从小到大的顺序进行排序。
- 对于字符、字符串类型:按照字符在ASCII码表中的数字升序进行排序。
- 字符串比较:从左往右依次比较,先出现对应ASII码值大的字符的字符串大
例如
abc,aaa进行比较,从左往右第一个字符一样,看下一个,a对应97,b对应98,后续字符不在比较,字符串aaa大于字符串abc若是
ab与aba进行比较,比较到第三位时,ab已经没有值,aba第三位为a,则字符串aba比ab更大
**a**对应**ASCII**码为 97,**A**对应**ASII**码值为65
对于存入自定义对象的数据存储的排序
方式一的方法不能满足需求时,可以考虑采取方式二的方法。两种方式都存在时是以方式二为准。
方式一:通过对自定义对象实现接口Comparable<E>来实现指定排序规则
自定义对象需要实现接口 Comparable<E>,并重写其中的抽象方法来进行指定默认排序规则
这里的泛型可直接填入该自定义对象,因为我们的目的就是为该自定义对象指定默认排序方式,自然也就能确定需要存入的数据类型。
public class Student implements Comparable<Student>{
//指定排序规则
//这里是指定按年龄大小排序,年龄小的在前面
//依据函数的结果判断大小,存入TreeSet集合时,会自动调用该方法
@Override
public int compareTo(Student o){
return this.getAge() - o.getAge();
}
// this:表示当前要添加的元素
// o:表示已经在红黑树存在的元素
// 返回值:
// 负数:认为要添加的元素是小的,存左边
// 正数:认为要添加的元素是大的,存右边
// 0:认为要添加的元素已经存在,舍弃
}存储过程中,this指向正在存储的节点,O则指向存储过程中每一次进行比较的节点。
方式二:创建TreeSet对象时,传入比较器Comparator制定规则
两种方式都存在时以方式二为准
采用例子来进行理解。
需求为:对存入的字符串进行的排序规则为,先按照长度进行排序,长度不一致时,再使用首字母排序
四个字符串分别为:c,ab,df,qwer
采用比较器排序的方法如下:
//创建集合,向集合中传入比较器 Comparator
//比较器Comparator为接口,所以需要传入比较器接口的实现类,这里选择直接创建一个
//泛型的位置直接传入String,是因为存入的对象类型已经可以确定为String
TreeSet<String> ts = new TreeSet<>(new Comparator<String>(){
//o1:表示当前要添加的元素
//o2:表示即将进行比对的元素,要添加的元素与该元素进行比对
@Override
public int compare(String o1, String o2){
//按照长度排序规则如下,返回值为负数表示当前添加元素更小,这里的小是指依据我们定义的排序规则来说
int i = o1.length() - o2.length();
//如果一样长按照首字母从左往右依次比对排序
//String是java替我们定义好的数据,属于自定义类型数据,并且java已经再源码中通过实现Comparable<String>接口
//在重写的CompareTo方法中,指定了默认排序规则为首字母排序,所以这里直接调用即可
//这里的三目运算符意思是,若长度一致即 i=0,则采用compartTo指定的方法比对,若 i 不为0,也就是长度不相等
//则直接返回i的值,让TreeSet来依据返回的i判断当前元素与比对元素的大小
i = i==0 ? o1.compareTo(o2) : i ;
return i;
}
});
//创建完成,进行添加数据
ts.add("c");
ts.add("ab");
ts.add("df");
ts.add("qwer");
//Comparator为函数试接口,可以对其进行lamba表达式进行简化
TreeSet<String> ts = new TreeSet<>((o1,o2)->{
//按照长度排序规则如下,返回值为负数表示当前添加元素更小,这里的小是指依据我们定义的排序规则来说
int i = o1.length() - o2.length();
//如果一样长按照首字母从左往右依次比对排序
//String是java替我们定义好的数据,属于自定义类型数据,并且java已经再源码中通过实现Comparable<String>接口
//在重写的CompareTo方法中,指定了默认排序规则为首字母排序,所以这里直接调用即可
//这里的三目运算符意思是,若长度一致即 i=0,则采用compartTo指定的方法比对,若 i 不为0,也就是长度不相等
//则直接返回i的值,让TreeSet来依据返回的i判断当前元素与比对元素的大小
i = i==0 ? o1.compareTo(o2) : i ;
return i;
});各个集合的使用场景
- 如果想要集合中的元素可重复
用ArrayList集合,基于数组的。(用的最多)
- 如果想要集合中的元素可重复,而且当前的增删操作明显多于查询
用LinkedList集合,基于链表的。
- 如果想对集合中的元素去重
用HashSet集合,基于哈希表的。(用的最多)
- 如果想对集合中的元素去重,而且保证存取顺序
用LinkedHashSet集合,基于哈希表和双链表,效率低于HashSet。
- 如果想对集合中的元素进行排序
用TreeSet集合,基于红黑树。后续也可以用List集合实现排序。
HashSet:无序、不重复、无索引
LinkedHashSet:有序(不能排序,是个链表加数组)、不重复、无索引
TreeSet:可排序、不重复、无索引
Set接口中的方法上基本上与Collection的APl一致。
Map(双列集合)
添加数据时,一次添加一对数据,以键值对的形式添加,key : value。
- 双列集合一次需要存一对数据,分别为键和值
- 键不能重复,值可以重复,若添加时已经有相同的键,新添加的键值对会覆盖旧的键值对
- 键和值是一一对应的,每一个键只能找到自己对应的值
- 键 + 值这个整体我们称之为 “ 键值对 ” 或者 “ 键值对对象 ”,在
Java中叫做Entry对象
Map是双列集合的顶层接口,它的功能是全部双列集合都可以继承使用的
| 方法 | 说明 |
|---|---|
| V put(K key,V value) | 添加元素,返回被覆盖的元素 |
| V remove(object key) | 根据键删除键值对元素 |
| void clear() | 移除所有的键值对元素 |
| boolean containsKey(object key) | 判断集合是否包含指定的键 |
| boolean containsValue(object value) | 判断集合是否包含指定的值 |
| boolean isEmpty() | 判断集合是否为空 |
| int size() | 集合的长度,也就是集合中键值对的个数 |
Set<String> keys =map.keySet(); | 获取所有的键,把这些键放到一个单列集合当中 |
String value = map.get(key); | 用map集合中的键获取对应的值value |
Map 集合的遍历方式
键找值遍历
//1.创建Map集合的对象
Map<String,String> map = new HashMap<>();
//2.添加元素
map.put("尹志平","小龙女");
map.put("郭靖","穆念慈");
map.put("欧阳克","黄蓉");
//3.通过键找值
//3.1获取所有的键,把这些键放到一个单列集合当中
Set<String> keys = map.keySet();
//3.2遍历单列集合,得到每一个键
for (String key :keys){
//System.out.println(key);
//3.3利用map集合中的键获取对应的值value
String value = map.get(key);
System.out.println(key +"="+value);
}键值对遍历(遍历中单个的对象是键值对对象Entry)
//1.创建Map集合的对象
Map<String,String> map = new HashMap<>();
//2.添加元素
//键:人物的外号
//值:人物的名字
map.put("标枪选手","马超");
map.put("人物挂件","明世隐");
map.put("御龙骑士","尹志平");
//3.Map集合的第二种遍历方式
//通过键值对对象进行遍历
//3.1通过一个方法entrySet获取所有的键值对对象,返回一个Set集合
//Entry是Map里的内部接口,所以要使用Map.Entry的形式
Set<Map.Entry<String,String>> entries =map.entrySet();
//3.2遍历entries这个集合,去得到里面的每一个键值对对象
for (Map.Entry<String,String> entry:entries){
//3.3利用entry调用get方法获取键和值
String key = entry.getKey();
String value = entry.getValue();
System.out.println(key +"="+value);
}lambda 表达式遍历
方法:default void forEach(Biconsumer<? super K,? super V> action)
结合lambda遍历Map集合
//1.创建Map集合的对象
Map<String,String> map = new HashMap<>();
//2.添加元素
//键:人物的名字
//值:名人名言
map.put("鲁迅","这句话是我说的");
map.put("曹操","不可能绝对不可能");
map.put("刘备","接着奏乐接着舞");
map.put("柯镇恶","看我眼色行事");
//3.利用1ambda表达式进行遍历
map.forEach(new BiConsumer<String,String>(){
@Override
public void accept(String key,String value){
System.out.println(key +"="+ value);
}
});
//lambda表达式形式为
map.forEach(
( key,value) ->
System.out.println(key +"="+ value)
);HashMap 的特点
- HashMap是Map里面的一个实现类
- 没有额外需要学习的特有方法,直接使用Map里面的方法就可以了。
- 特点都是由键决定的:无序、不重复、无索引
- HashMap跟HashSet底层原理是一模一样的,都是哈希表结构
总结
- HashMap底层是哈希表结构的
- 依赖hashCode方法和equals方法保证键的唯一
- 如果键存储的是自定义对象需要重写hashCodee利equals方法
- 如果值存储自定义对象,不需要重写hashCode和equals方法
LinkedHashMap 集合
- 由键决定:有序、不重复、无索引。
- 这里的有序指的是保证存储和取出的元素顺序一致
- 原理:底层数据结构是依然哈希表,只是每个键值对元素又额外的多了一个双链表的机制记录存储的顺序。
TreeMap 集合
- TreeMap跟TreeSet底层原理一样,都是红黑树结构的。
- 由键决定特性:不重复、无索引、可排序
- 可排序:对键进行排序。
- 注意:默认按照键的从小到大进行排序,也可以自己规定键的排序规则
指定排序规则的两种方式
- 实现
comparable接口,指定比较规则。 - 创建集合时传递
comparator比较器对象,指定比较规则,两个方法同时出现时,以比较器对象方法为主
面试八股HashMap以及TreeMap的源码解析,底层原理,B站有讲解,面试八股类属于
switch 方法
在 Java 中,switch 语句的行为与 C++ 有所不同。
在 Java 7 及之前的版本中,switch 语句的行为和 C++ 类似,如果没有 break 语句,程序会继续执行下一个 case 分支(即 “穿透” 现象)。
从 Java 14 开始,引入了 增强型 switch 表达式(switch expression),支持使用箭头语法 ->,这种情况下不需要写 break,程序会自动退出当前 switch 结构。
// 传统写法(需要 break)
int day = 2;
switch (day) {
case 1:
System.out.println("星期一");
break;
case 2:
System.out.println("星期二");
break;
default:
System.out.println("其他");
}
// 增强型写法(不需要 break)
int day = 2;
switch (day) {
case 1 -> System.out.println("星期一");
case 2 -> System.out.println("星期二");
default -> System.out.println("其他");
}总结
- 使用传统的
case:语法时,需要break来避免穿透 - 使用新的
case ->语法时,不需要break,会自动退出 - 增强型 switch 是 Java 14 引入的特性,需要注意项目使用的 Java 版本是否支持
权限修饰符
权限修饰符的作用范围划分如下:
private < 空着不写 < protected < public
| 修饰符 | 同一个类中 | 同一个包中其他类 | 不同包下的子类 | 不同包下的无关类 |
|---|---|---|---|---|
| private | √ | |||
| 空着不写 | √ | √ | ||
| protected | √ | √ | √ | |
| public | √ | √ | √ | √ |
权限修饰符的使用规则
实际开发中,一般只用private和public
- 成员变量私有
- 方法公开
- 特例:如果方法中的代码是抽取其他方法中共性代码,这个方法一般也私有(不让外界使用)
代码块
代码块的分类
局部代码块,构造代码块,静态代码块
局部代码块的作用
提前结束变量的生命周期 (已淘汰),局部变量只在它所在的大括号(代码块)里面有效
构造代码块的作用
抽取构造方法中的重复代码(不够灵活)。
写在成员位置的代码块
- 作用:可以把多个构造方法中重复的代码抽取出来
- 执行时机:我们在创建本类对象的时候会先执行构造代码块再执行
静态代码块的作用
数据的初始化 (重点),随着类的加载而加载的,并且只执行一次。
对象的继承
什么时候用继承
当类与类之间,存在相同(共性)的内容,并满足子类是父类中的一种,就可以考虑使用继承,来优化代码
可以把多个子类中重复的代码抽取到父类中,子类可以直接使用,减少代码冗余,提高代码的复用性
继承的格式
public class 子类 extends 父类{ }继承后子类的特点
- 子类可以得到父类的属性和行为,子类可以使用,也就是父类中的一些内容,创建子类时不需要再写一遍,从父类中直接继承得来了。
- 子类可以在父类的基础上新增其他功能,子类更强大。
- 好比 person 类中定义了 身高,姓名,脸型。后续的男性,女性,同样具有 person 类中的属性,那么男性,女性就可以作为 person 类的子类,通过继承父类 person 类,来获得其中定义得属性,从而达到简化的目的(若不继承,则需要在男性,女性类中再各自定义一次相同的属性,用继承则省去了这一步骤),但是不是所有内容都可以继承。
继承的特点
Java只支持单继承,不支持多继承,但支持多层继承
- 单继承:一个子类只能继承一个父类
- 不支持多继承:子类不能同时继承多个父类
- 多层继承:子类A继承父类B,父类B可以继承父类C,每一个类都直接或者间接的继承于
Object
子类继承父类中的哪些内容
- 误区1:父类私有的东西,子类就无法继承
- 误区2:父类中非私有的成员,就被子类继承下来了
一个类中只有三部分内容,构造方法(创建对象时对对象进行初始化操作),成员变量(类的属性),成员方法(类的功能)。
修饰符为三个,公共的(public),私有的(private),受保护的(protected)。
关于构造方法
- 构造方法的名称必须与当前类名相同,而子类与父类名称不同,若强行继承会导致名称冲突。
- 子类有自己的属性和初始化逻辑,需要通过自身的构造方法来完成,而非依赖父类构造方法
无论父类的构造方法是何种类型,子类都不能继承父类的构造方法。
补充:如果一个类中没有构造方法,虚拟机会自动的给你添加一个默认的空参构造
假设构造方法可以被继承:
//父类如下
publicC class Fu {
string name;
int age;
public Fu(){} //父类的空参构造
public Fu(Stringname,int age){ //父类的有参构造
this.name=name;
this.age=age;
}
}
//若是子类能继承,那子类中的构造方法如下
public class Zi extends Fu {
public Fu(){} //子类继承来的无参构造方法
public Fu(String name, int age){ //子类继承来的有参构造方法
this.name e = name;
this.age = age;
}
}若真如上诉所说,那子类中的构造方法就不满足构造方法的定义,子类的类名为 Zi 构造方法名字也应该为 Zi 但是上面继承来的却为 Fu ,显然不符合构造方法定义,所以子类不能继承父类的构造方法。
关于成员变量
对于父类中的成员变量,子类只能继承非私有变量。
例如父类中若是非私有的成员变量,子类中继承来之后,就与子类中定义的非私有的成员变量调用方法一样,对象名点成员名即可调用。
若是父类中的私有变量,子类无法继承。
运行代码如下:
public class Fu {
/*
父类中定义一个公共变量 name 一个私有变量 age
*/
String name;
private int age;
/*
定义私有变量的get和set方法,验证子类是否能继承父类的私有变量
*/
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}public class Zi extends Fu{
/*
定义子类对象,验证是否能调用父类的私有变量
目前来看:子类从父类中继承的内容为
get和set方法,公有变量name,私有变量age(还不知能否继承)
*/
}public class ZiExtendsFu {
public static void main(String[] args) {
/*
关于子类继承父类的不同类型成员变量的调用方法
对于父类中非私有的成员变量,调用规则与子类中自己定义的非私有的成员变量一样
直接通过: 对象.成员属性 即可
父类中的私有变量,只能通过父类中定义的方法来访问,谁定义的成员,
谁就掌控访问权;其他类(包括子类)即使 “持有” 这个成员,也不能越权访问
也就是为什么子类只能通过调用继承来的父类setAge和getAge方法来访问私有变量age
自己直接在子类中定义setAge和getAge方法不能访问age的原因
*/
//尝试利用子类对象调用继承来的父类的 非私有变量 name 以及私有变量 age
//调用的是从父类继承来的非私有变量name,与子类中自己定义的非私有变量调用方式一致
Zi son=new Zi();
son.name="这是子类的继承来的公共变量son.name";
//对父类公共变量赋值,验证父类变量与子类变量是否为同一变量(若是则两者的值一样)
Fu father=new Fu();
father.name="这是父类的公有变量father.name";
//打印父类和子类的公共变量
System.out.println(father.name);
System.out.println(son.name);
/*
打印结果如下:
这是父类的公有变量father.name
这是子类的继承来的公共变量son.name
父类与子类继承来的公共变量并不一致。
*/
/*
通过分别调用父类的get和set方法以及子类继承来的get和set方法来验证
子类能否继承来父类的私有变量
*/
//首先调用父类的set方法给私有变量赋值
father.setAge(88);
//调用子类继承来的set方法来进行赋值
son.setAge(22);
//分别调用父类和子类的get方法来获取age的值,目的是看子类继承来的set和get方法访问的
//是不是父类的私有变量,如果是,结果应同为88!
System.out.println("父类father的get方法:"+father.getAge());
System.out.println("子类son的get方法:"+son.getAge());
/*
结果如下:
父类father的get方法:88
子类son的get方法:22
如果子类不能继承父类的私有变量,那上面子类set方法赋值的age是谁的呢,get的age又是谁的呢
依据结果来看的话,两者赋值的age并不是同一变量
*/
}
}运行过程内存空间变化如下:
创建对象的时候不仅加载子类的字节码文件,还要将直接继承和间接继承的父类的字节码进行加载。
首先运行 main 方法,此时 main 方法进栈,
然后第一行代码 Zi son=new Zi();运行时,虚拟机先把 Zi 子类的字节码文件加载到方法区,然后发现 Zi 类继承了 Fu 类,再把 Fu 类的字节码文件加载到方法区,然后因为有 new 所以在堆中开辟一段空间,,空间中一部分是从父类继承过来的内容,一部分是子类自己定义的内容。并把地址值返回给变量 son。
当通过son.name="小明";调用子类 Zi 继承来的父类变量时,虚拟机会先依据 son 的地址值,到堆中寻找开辟的对应空间,然后先在存储 Zi 子类中内容的内存区寻找调用的 name 变量,发现没有,再去存放从父类继承来的内容的内存区中寻找 name 变量。
当运行代码son.age=18;时,由于 age 变量在父类中就是 private 私有变量,所以当子类调用时是无法直接访问的,只能通过继承的父类中定义的相关操作私有变量的方法来访问。运行时,虚拟机也是先在存储 Zi 子类中内容的内存区寻找调用的 age 变量,发现没有,再去存放从父类的内容的内存区中寻找 age 变量,但是这次的 age 变量是private私有的,所以是找不到的,调用失败。
最后所有代码运行完毕,main方法出栈,指向堆内存的son变量也消失,堆中开辟的内存由于没有任何指向它地址的变量,也被回收销毁。至此代码运行完毕,虚拟机关闭。
总结
主要是对子类中如何访问父类的私有变量,进行的推理验证。
子类不能继承父类的私有变量,父类相当于为子类提供了一个创建模板,子类在继承时,继承的是哪个父类就以哪个父类为模板,创建独自属于自己的模板,在创建对象时就以这个模板来开辟空间,决定对象中都包含哪些变量和方法。模板就是指定了该类以及该类的实例对象中都包含哪些变量和方法。
子类继承父类实际上是在父类的模板(包括父类中的非静态变量信息),外加是子类独有的内容,整合到一起作为子类继承之后的模板。所以子类继承父类之后,父类中的私有变量是会在子类的内存空间中存在的,但是子类不能直接访问。
谁定义的方法,谁就有权访问自己类中的私有成员,一个类的方法可以访问该类所有实例(包括子类实例)中的私有成员。
因为父类中的私有变量肯定是在父类中定义的,所以子类只能通过父类继承来的方法(例如set和get)操作私有变量的方法来操作在父类中定义的私有变量,在子类中定义的方法不能操作这些私有变量(例如常见的set和get方法)
/*
通过分别调用父类的get和set方法以及子类继承来的get和set方法来验证
子类能否继承来父类的私有变量
*/
//首先调用父类的set方法给私有变量赋值
father.setAge(88);
//调用子类继承来的set方法来进行赋值
son.setAge(22);
//分别调用父类和子类的get方法来获取age的值,目的是看子类继承来的set和get方法访问的
//是不是父类的私有变量,如果是,结果应同为88!
System.out.println("父类father的get方法:"+father.getAge());
System.out.println("子类son的get方法:"+son.getAge());
/*
结果如下:
父类father的get方法:88
子类son的get方法:22
如果子类不能继承父类的私有变量,那上面子类set方法赋值的age是谁的呢,get的age又是谁的呢
依据结果来看的话,两者赋值的age并不是同一变量
*/
}
}疑问1
如果依据子类不能继承父类的私有变量这一规定,那这些代码为什么会出现这样的结果,是否可以理解为,子类并不能继承父类的私有变量,但是从子类对象的内存区中是存在与父类的私有变量相同的同为私有变量的变量,并且与父类的私有变量并不是同一个变量?
但是如何解释,子类调用从父类继承来的set方法进行赋值为22,父类调用自身的setAge方法给私有变量age赋值为88,但是父类调用自身的getAge方法得到的值是88,而当子类调用继承来的getAge方法时得到的值是22,既然子类通过继承来的setAge和getAge方法访问的是父类中的私有变量age,为什么两者都用setAge方法进行赋值,后赋值的数据22为什么没有覆盖先赋值的数据88,两者都通过getAge方法获取的值为什么不一样?
解答1如下
你的困惑核心在于对 “对象实例独立性” 和 “继承中成员变量存储方式” 的理解偏差。我们可以从 “对象实例的内存模型” 和 “方法调用的本质” 两个角度来解释:
一、关键前提:father和son是两个完全独立的对象
你代码中的father是父类的一个实例,son是子类的一个实例。这两个对象在内存中是完全独立的存储空间,就像两个独立的盒子:
father盒子里有父类定义的所有成员(包括私有变量age);son盒子里不仅有子类自己的成员,还包含父类定义的所有成员(包括私有变量age)—— 这部分来自父类的成员,是son对象自己的,不是father对象的。
二、为什么father.setAge(88)和son.setAge(22)互不影响?
父类的setAge方法作用对象是 “当前实例自己的age”
父类的setAge方法本质是:给调用该方法的对象自己的私有变量age赋值。- 当
father.setAge(88)时,是给father对象自己的age变量赋值 88; - 当
son.setAge(22)时,是给son对象自己的age变量赋值 22(这个age是son对象中从父类继承来的那部分成员,属于son自己)。
- 当
两个对象的age变量是完全独立的
father和son是两个不同的对象,它们的age变量存储在各自的内存空间中,彼此没有关联。就像你给 “父亲的盒子” 放了 88,给 “儿子的盒子” 放了 22,两者自然不同。
三、关于 “子类是否包含父类私有变量” 的澄清
- 子类不能继承父类的私有变量(即子类无法直接访问父类定义的私有变量);
- 但子类对象的内存中会包含父类定义的私有变量(作为对象整体的一部分),只是子类无法直接操作,必须通过父类提供的
get/set方法(这些方法被子类继承了)来操作自己对象中的这部分私有变量。
简单说:子类对象 “包含” 父类的私有变量(作为自身内存的一部分),但不 “继承”(即不能直接访问)。get/set方法让子类可以间接操作自己对象中的这部分变量。
四、用比喻总结
- 父类就像一个 “模板”,定义了有一个带锁的抽屉(私有变量
age)和两把钥匙(setAge和getAge方法)。 - 子类继承了这个模板,所以每个子类对象也有一个带锁的抽屉(来自父类模板的定义),并且也拿到了钥匙(继承的
set/get方法)。 father对象是用父类模板造的柜子,它的抽屉里放了 88;son对象是用子类模板造的柜子(包含父类的抽屉),它的抽屉里放了 22;- 两个柜子的抽屉是独立的,钥匙只能打开自己柜子的抽屉,所以结果自然不同。
因此,你的代码结果恰恰证明了:每个对象(包括子类对象)都有自己的父类私有变量副本,通过继承的get/set方法操作的是 “当前对象自己的副本”,而不是父类对象的变量,也不是所有对象共享一个变量。这与 “子类不能继承父类私有变量(指直接访问权)” 的规则并不矛盾。
疑问2
子类不能继承父类的私有变量(即子类无法直接访问父类定义的私有变量);但子类对象的内存中会包含父类定义的私有变量(作为对象整体的一部分),只是子类无法直接操作,必须通过父类提供的get/set方法(这些方法被子类继承了)来操作自己对象中的这部分私有变量。
简单说:子类对象 “包含” 父类的私有变量(作为自身内存的一部分),但不 “继承”(即不能直接访问)。get/set方法让子类可以间接操作自己对象中的这部分变量。
- 所以说子类不能继承父类的私有变量其实类似于子类无法直接访问父类的私有变量这一说法,
- 还是说子类对父类的继承本质上是把父类作为一个模板,然后依次为基础创建一个包含父类所有非静态成员的空间,这个空间作为子类的创建模板,后续子类实例的创建也是依据此模板。
解答2如下
你的理解方向是对的,但需要更精准地区分 “继承的权限” 和 “对象的内存结构” 这两个核心概念。我们可以从 “类的继承机制” 和 “对象的内存布局” 两个层面拆解,就能更清晰地理解:
一、“子类不能继承父类私有变量” 的核心:权限的限制
Java 中 “继承” 的本质不仅是代码复用,更包含成员的可访问性。这里的 “不能继承”,严格指的是:
子类无法获得对父类私有变量的直接访问权(包括直接引用变量名、修改值等)。私有变量的访问权限被严格限制在父类内部,子类即使 “知道” 它的存在,也不能直接操作 —— 这是 Java 封装性的核心体现(私有成员只能被自身类访问)。
例如:父类有private int age;,子类中直接写this.age会编译报错,这就是 “不能继承” 的直接证明:子类没有获得对这个变量的直接访问权限。
二、“子类对象包含父类私有变量” 的本质:对象结构的延续
当创建子类对象时,JVM 的内存分配逻辑是:
先为父类的所有非静态成员(包括私有变量)分配内存,再为子类自己的非静态成员分配内存。
也就是说:
- 子类对象的内存空间 = 父类成员(含私有)的内存块 + 子类独有成员的内存块。
这种结构的原因是:子类需要完整 “继承” 父类的行为(方法),而父类的方法可能依赖于自己的私有变量(比如getAge()本质是返回父类私有变量age的值)。如果子类对象中没有父类私有变量的内存空间,父类的方法就无法在子类对象中正常运行(因为方法找不到它需要操作的变量)。
但注意:这个 “包含” 是对象内存结构的必然结果,而非 “继承了变量的访问权”。私有变量的访问权仍然只属于父类,子类只能通过父类提供的公有方法(如get/set)间接操作自己对象中这部分内存。
三、继承的本质:父类作为模板,子类在此基础上扩展
可以这样理解:
- 父类定义了一套 “基础模板”,包含成员变量(公开、保护、私有等)和方法。
- 子类继承时,会 “复制” 这份基础模板的结构(但不复制私有变量的访问权),并在其基础上添加自己的成员变量和方法,形成子类的 “扩展模板”。
- 当创建子类对象时,JVM 会按照 “扩展模板” 分配内存:先分配基础模板(父类)的所有成员(含私有),再分配子类扩展的成员。
因此,子类对象的内存中必然包含父类私有变量(因为要支撑父类方法的运行),但子类对这些变量没有直接访问权(因为继承不传递私有成员的访问权)。
总结
- “子类不能继承父类私有变量” → 指子类没有获得对私有变量的直接访问权(封装性的限制)。
- “子类对象包含父类私有变量” → 指内存结构上必须包含这些变量,才能支撑父类方法在子类对象中运行(继承行为的需要)。
- 继承的本质 → 父类作为基础模板,子类扩展其结构并继承可访问的成员(方法和非私有变量),私有变量作为基础模板的一部分存在于子类对象中,但访问权仍归父类。
这种设计既保证了封装性(私有变量不被直接访问),又实现了代码复用(子类可通过父类方法操作私有变量)。
疑问3
所以子类对象访问自身内存空间中的父类的私有变量是采用继承来的父类的setAge和getAge方法,但是调用的原理是什么呢?
如果是调用成员方法那样在调用方法时默认传入调用者的地址值,然后利用this变量调用自身的成员变量,那为什么在子类中直接定义子类的setAge和getAge方法却不能访问父类的私有age变量呢?
解答3如下
你的问题触及了 Java 中方法访问权限的本质和this 关键字的作用范围,核心在于:方法能访问哪些成员,取决于 “方法所在的类”,而不是 “调用方法的对象类型”。我们分两部分拆解:
一、子类对象调用继承的父类setAge/getAge方法能访问父类私有变量的原理
父类的setAge和getAge方法是在父类中定义的,这一点是关键。
当子类对象(如son)调用继承的setAge(22)时,底层逻辑是:
- 方法调用时,JVM 会将当前对象的引用(即
son对象的地址)隐式传入方法,作为this关键字的值(this指向调用者son对象)。 - 父类的
setAge方法在父类中定义,根据 Java 的访问规则:一个类的方法可以访问该类所有实例(包括子类实例)中的私有成员。
也就是说,父类方法有权限操作 “任何父类实例或子类实例中属于父类定义的私有变量”(因为这些变量本质是父类自己定义的成员)。
形象地说:
- 父类就像一把 “钥匙”(
set/get方法),这把钥匙是父类自己打造的,天然能打开 “父类定义的锁”(私有变量age)。 - 子类对象虽然是 “子类类型”,但它的内存中包含 “父类的锁”(
age变量),父类的钥匙(方法)自然能打开这把锁,无论这把锁装在哪个对象上(父类对象或子类对象)。
二、子类自己定义的setAge/getAge方法不能访问父类私有变量的原因
如果子类自己写一个setAge方法(不是继承父类的),比如:
public class Son extends Father {
// 子类自己定义的setAge方法
public void setAge(int age) {
this.age = age; // 编译报错:无法访问age
}
}编译会报错,原因是:
- 这个
setAge方法是在子类中定义的,属于子类的方法。 - 根据 Java 的封装规则:私有成员(父类的age)的访问权限仅限于定义它的类(父类),其他类(包括子类)的方法都无权直接访问,无论这个成员是否在同一个对象中。
简单说:
- 子类自己的方法是 “子类打造的钥匙”,这把钥匙没有权限打开 “父类的锁”(私有变量
age),即使这把锁装在子类自己的对象上。 - 访问权限的判断依据是 “方法属于哪个类”,而不是 “变量在哪个对象中”。子类方法属于子类,自然无权访问父类的私有成员。
总结:核心区别在于 “方法所在的类”
| 方法来源 | 方法所在的类 | 能否访问父类私有变量age | 原因 |
|---|---|---|---|
| 继承自父类 | 父类 | 能 | 父类方法有权访问自身定义的私有成员(无论对象类型) |
| 子类自己定义 | 子类 | 不能 | 子类方法无权访问父类定义的私有成员 |
一句话:谁定义的方法,谁就有权访问自己类中的私有成员;子类方法和父类私有成员分属不同的类,自然无权访问。这正是 Java 封装性的严格体现 —— 私有成员的访问权被严格限制在定义它的类内部。
疑问4
也就是说尽管子类内存中包含有父类定义的私有变量,但是访问权限只有父类中定义的关于私有变量的方法才能直接访问,即使这个私有变量的地址是在子类内存中,但是它是父类定义的私有变量,也只有父类中定义的关于这个私有变量的方法才能访问
解答4如下
你的理解完全正确!这正是 Java 封装性的核心逻辑:私有成员的访问权限严格绑定到 “定义它的类”,与 “变量实际存储在哪个对象的内存中” 无关。
具体来说:
- 父类的私有变量
age,无论它存在于父类对象的内存中,还是子类对象的内存中,它的 “所有权” 始终属于父类。 - 只有父类中定义的方法(如
setAge、getAge)才有权限直接访问这个变量,无论这些方法被父类对象调用还是子类对象调用(因为方法的 “所有权” 属于父类)。 - 子类即使知道自己的内存中有这个变量,也不能通过自身定义的方法直接访问(因为子类方法的 “所有权” 属于子类,无权访问父类的私有成员)。
这种设计确保了:
- 父类可以安全地隐藏内部实现细节(私有变量)。
- 子类只能通过父类允许的方式(公开方法)与这些细节交互,保证了数据的安全性和一致性。
简单说:谁定义的成员,谁就掌控访问权;其他类(包括子类)即使 “持有” 这个成员,也不能越权访问。
疑问5
所以这也就是,为什么子类只能调用继承来的父类中的方法来访问处在子类内存空间的父类定义的私有变量
解答5如下
可以从 “权限归属” 和 “内存结构” 两个角度进一步拆解:
- 权限归属:父类方法是 “钥匙”
父类的私有变量(比如age)的访问权限仅归父类所有。子类虽然在内存中 “包含” 这个变量(作为继承自父类的部分),但自身没有直接访问的权限。
而父类定义的setAge、getAge等方法,由于是父类自身的成员,拥有访问该私有变量的权限。当这些方法被子类继承后,子类对象调用它们时,本质上是通过 “父类赋予的钥匙” 在操作 —— 方法内部通过this(此时指向子类对象)访问的正是子类内存中那部分属于父类的私有变量。 - 内存结构:子类对象包含父类部分
子类对象的内存布局中,会先包含父类所有成员(包括私有变量)的空间,再添加子类自己的成员。但私有变量的 “所有权” 标签始终是父类,只有父类的方法能 “识别” 并操作这个标签的变量。
子类自己定义的方法没有这个 “识别权限”,因此即使变量就在同一个对象的内存中,子类方法也无法直接访问。
总结来说:
子类对象内存中的父类私有变量,就像一个 “贴了父类标签的箱子”。只有父类自己制作的 “钥匙”(即父类定义的方法)才能打开它,子类继承了这把钥匙(方法),所以能间接操作;但子类自己配的 “钥匙”(子类自己定义的方法)无法识别这个标签,自然打不开。
这正是 Java 封装性的体现:数据的访问权牢牢掌握在定义它的类手中,即使其他类 “持有” 数据,也不能越权操作。
关于成员方法
每一个类都有一个虚方法表,只有非private私有,非static静态,非final的方法才能进入虚方法表中,当子类继承父类时,父类的虚方法表也会被继承下来(相当子类复制一份作为自己的虚方法表),并把子类自己的非private私有,非static静态,非final的方法放入到虚方法表中。
当子类调用方法时,虚拟机会先从创建的子类对象的虚方法表中寻找所调用的函数地址,若子类虚方法表中没有便去父类的虚方法表中寻找,若不满足非private私有,非static静态,非final三个条件则找不到方法,调用失败。
虚方法表的基本概念
虚方法表是面向对象编程语言实现多态的关键机制,每个包含虚方法的类都会有一个对应的虚方法表,表中存储着该类所有虚方法的地址。
方法进入虚方法表的条件
你提到的 "非 private、非 static、非 final" 的方法才能进入虚方法表,这个总结是准确的:
- private 方法:属于类私有,不会被继承和重写,因此不会进入虚方法表
- static 方法:属于类级别的方法,不属于对象,不涉及多态,因此不会进入虚方法表
- final 方法:虽然可以被继承,但不能被重写,因此也不需要进入虚方法表
子类与父类虚方法表的关系
当子类继承父类时,会创建一个新的虚方法表,而不是简单复制父类的虚方法表:
- 子类虚方法表中首先包含父类所有可继承的虚方法条目
- 如果子类重写了父类的某个虚方法,子类虚方法表中对应的条目会被替换为子类实现的方法地址
- 子类新增的虚方法会被添加到自己的虚方法表中
方法调用过程
当通过对象调用方法时,虚拟机会:
- 首先检查该方法是否为虚方法(即是否在虚方法表中)
- 如果是虚方法,通过对象的 vptr(虚方法表指针)找到对应的虚方法表
- 在虚方法表中查找并调用相应的方法
- 如果方法不在虚方法表中(如 private、static、final 方法),则直接进行静态绑定调用
这种机制确保了在运行时能够根据对象的实际类型来调用正确的方法实现,从而实现了多态性。对于非虚方法,编译器在编译时就可以确定调用哪个方法,这称为静态绑定;而对于虚方法,则是在运行时确定,称为动态绑定。
继承内容的调用
成员变量的访问
继承中成员变量访问特点:
就近原则:先在局部位置找,本类成员位置找,父类成员位置找,逐级往上
如果出现了重名的成员变量怎么办
System.out.println(name) ; //从局部位置开始往上找
System.out.println(this.name); //从本类成员位置开始往上找
System.out.println(super.name); //从父类成员位置开始往上找
System.out.println(super.super.name); //这样的调用是不合法的,会报错,super不能连续使用调用案例
public class Fu {
String name"Fu";
}
public class Zi extends Fu{
String name="zi";
public void zishow(){
String name = "zishow";
System.out.println(name); //就近原则,先从定义的局部变量中找,若没有再去类的成员变量找 zishow
System.out.println(this.name); //直接调用本类的成员变量 Zi
System.out.println(super.name); //直接调用本类的父类的成员变量 Fu
}
}成员方法的访问
直接调用满足就近原则:谁离我近,我就用谁
super调用,直接访问父类
非private私有,非protected保护,非static静态的成员方法(也就是可以添加到虚方法表中的方法),可以被重写。
- 不被重写的成员方法与从父类继承来的成员方法内容一样,调用时若直接调用,调用的是从父类继承来的成员方法,若用
super调用,调用的是父类中的成员方法,但是由于没有重写,所以两种调用方式结果一样,只是底层调用原理不一样。
class Person {
public void eat(){
System.out.println("吃米饭,吃菜");
}
public void drink(){
System.out.println("喝开水");
}
}
class Student extends Person{
public void lunch(){
//先在本类中查看eat和drink方法,就会调用子类的,如果没有,就会调用从父类中继承下来的eat和drink方法
this.eat(); //此处this一般不写,程序会自动加上(隐式调用)
this.drink();
}
}
public static void main(String[] args) {
Student s = new Student();
s.lunch();
}上述案例调用时现在本类中找有没有被重写的方法,有的话就调用重写后的方法(重写后会覆盖从父类继承的成员方法),没有重写就调用从父类继承来的成员方法,若还是找不到指定方法就报错(说明没有这个方法)
- 被重写的成员方法重写的方法是与对应的被重写的方法名称,形参列表都要一致,不同的只是方法中的内容。一般继承之后,每个类都带有各自自己的虚方法表,重写的方法也就是虚方法表中的方法,重写之后会把虚方法表中对应的方法覆盖,此时再进行调用调用的就是重写后的方法,若要调用原来的重写之前的方法,用 super 来调用即可。
class Person {
public void eat(){
System.out.println("吃米饭,吃菜");
}
public void drink(){
System.out.println("喝开水");
}
}
//留学生
//应用场景:当父类中方法,不能满足子类现在的需求时,我们就需要把这个方法进行重写。
//注意:子类中重写的方法上面需要加上@override
class OverseasStudent extends Person{
public void lunch(){
this.eat();//吃意大利面 调用的是继承来的eat方法,因为被重写所以原来继承来的方法被覆盖了
this.drink();//喝凉水 调用的是继承来的drink方法,因为被重写所以原来继承来的方法被覆盖了
super.eat();//吃米饭,吃菜 调用的是父类的eat方法(注意不是子类继承来的哪个eat)
super.drink();//喝开水 调用的是父类的drink方法(继承来的方法已经被覆盖掉了)
}
@Override
public void eat(){ //对虚方法表中的方法进行了重写
System.out.println("吃意大利面");
}
@Override
public void drink(){ //对虚方法表中的方法进行了重写
System.out.println("喝凉水");
}
}
public static void main(String[] args) {
Student s = new Student();
s.lunch();
}应用场景:当父类中方法,不能满足子类现在的需求时,我们就需要把这个方法进行重写
方法的重写
当父类的方法不能满足子类现在的需求时,需要进行方法重写
在继承体系中,子类出现了和父类中一模一样的方法声明,我们就称子类这个方法是重写的方法
- 重写方法的名称、形参列表必须与父类中的一致。
- 子类重写父类方法时,访问权限子类必须大于等于父类(暂时了解:空着不写<protected<public)
- 子类重写父类方法时,返回值类型子类必须小于等于父类
- 建议:重写的方法尽量和父类保持一致。
- 只有被添加到虚方法表中的方法才能被重写(非
private私有,非protected保护,非static静态)。
@Override重写注解
- Override是放在重写后的方法上,校验子类重写时语法是否正确
- 加上注解后如果有红色波浪线,表示语法错误。
- 建议重写方法都加@Override注解,代码安全,优雅!
构造方法的访问
父类中的构造方法不会被子类继承。
子类中所有的构造方法默认先访问父类中的无参构造,再执行自己。
为什么子类所有构造方法默认先访问父类的无参构造
当创建子类对象时,子类的构造方法会先调用父类的构造方法,完成父类部分的初始化,再执行自身的初始化逻辑。这是因为子类继承了父类的属性和方法,必须先确保父类的状态是正确的。
具体规则:
- 如果子类的构造方法中没有显式调用父类的构造方法,Java 会默认在子类构造方法的第一行插入
super()(调用父类的无参构造)。 - 如果父类没有无参构造方法(例如只定义了有参构造),则子类的构造方法必须显式调用父类的有参构造方法(使用
super(参数)),否则会编译报错。 - 子类构造方法的第一行语句默认都是:super( ),不写也存在(虚拟机自动添加),且必须在第一行。
示例 1:默认调用父类无参构造
class Parent {
// 父类无参构造
public Parent() {
System.out.println("父类无参构造执行");
}
}
class Child extends Parent {
// 子类无参构造
public Child() {
// 隐含 super(),调用父类无参构造
System.out.println("子类无参构造执行");
}
}
public class Test {
public static void main(String[] args) {
Child child = new Child();
// 输出顺序:
// 父类无参构造执行
// 子类无参构造执行
}
}示例 2:父类只有有参构造时,子类必须显式调用
class Parent {
// 父类只有有参构造,无默认无参构造
public Parent(String name) {
System.out.println("父类有参构造执行,name=" + name);
}
}
class Child extends Parent {
public Child() {
// 必须显式调用父类有参构造,否则编译报错
super("父类"); // 调用父类的有参构造
System.out.println("子类无参构造执行");
}
}
public class Test {
public static void main(String[] args) {
Child child = new Child();
// 输出顺序:
// 父类有参构造执行,name=父类
// 子类无参构造执行
}
}为什么要先调用父类构造方法?
- 继承的本质:子类是对父类的扩展,父类的属性和方法是子类的基础。只有先初始化父类,子类才能安全地使用父类的资源。
- 避免逻辑错误:如果子类先初始化自身,再调用父类构造,可能导致父类的属性未初始化,子类使用时出现异常。
调用父类构造方式
在 Java 中,当父类有无参构造函数时,子类的构造函数处理规则如下:
- 默认自动调用:如果子类的构造函数没有显式调用父类的任何构造函数(包括无参构造),编译器会自动在子类构造函数的第一行插入对父类无参构造的调用(即
super())。 - 无需显式要求所有构造函数都调用:子类中可以有多个构造函数,只要每个构造函数最终能通过某种方式(直接或间接)调用到父类的构造函数即可:
- 可以直接在子类构造函数中用
super()调用父类无参构造 - 也可以通过
this(...)调用子类的其他构造函数,最终由那个构造函数完成对父类构造的调用
- 可以直接在子类构造函数中用
- 示例说明:
class Parent {
// 父类无参构造
public Parent() {
System.out.println("Parent无参构造");
}
}
class Child extends Parent {
// 构造函数1:未显式调用super(),编译器会自动添加
public Child() {
// 隐含 super()
System.out.println("Child无参构造");
}
// 构造函数2:通过this()调用本类其他构造函数
public Child(int x) {
this(); // 调用Child(),而Child()会调用Parent()
System.out.println("Child带参构造");
}
}总结
子类并非要求每个构造函数都显式调用父类无参构造,只要保证所有构造函数最终都能通过直接(super())或间接(this(...) 链)的方式调用到父类构造即可。由于父类存在无参构造,编译器会在必要时自动插入 super(),因此通常不需要显式编写。
当在子类中用this访问本类的构造方法时,
public class Student {
String name;
int age;
String school;
//需求:
//默认为传智大学
public Student(){
//表示调用本类其他构造方法
//细节:虚拟机就不会再在添加super(); 通过调用有参构造,间接调用了super(),所以不需添加
this( name: null, age:0, school: "传智大学");
System.out.println("1234"); //其他语句必须写在调用构造方法的下面,因为使用对象前必须先进行初始化
}
public Student(String name, int age, String school) { //有参构造
this.name=name;
this.age=age;
this.school1 = school;
}
}this,super 的使用
- this:理解为一个变量,表示当前方法调用者的地址值;当进行调用类中的成员方法时,会隐试传入一个
this变量,这个变量包含了调用者的地址值,当进行调用成员属性时,也会隐试使用到this变量,this变量的类型与调用者类型一致。案例如下:
public class Student {
String name;
int age;
public void show(Student this){ //此处this是隐试调用,虚拟机自行添加,this类型与调用者类型一致
System.out.println(this.name + "," + this.age);
}
}
//测试代码如下
public class Test {
public static void main(String[] args){
Student s =new Student();
s.name = "zhangsan";
s.age = 23;
s.show(); //调用Student类中的show方法,把调用者s的地址值传给this
}
}- super:代表父类存储空间
表格对比
| 关键字 | 访问成员变量 | 访问成员方法 | 访问构造方法 |
|---|---|---|---|
| this | this.成员变量 访问本类成员变量 | this.成员方法 访问本类成员方法 | this( ) 访问本类构造方法 |
| super | super.成员变量 访问父类成员变量 | super.成员方法 访问父类成员方法 | super( ) 访问父类构造方法 |
关于this的用法:
(1)Java 不允许在类的字段初始化块或方法外部直接使用 this 关键字。具体分析如下:
public class User extends Person {
// ❌ 错误:字段初始化块中不能直接使用 this
this.country="子类的";
// 其他代码...
}在代码中,this.country="子类的"; 这行位于类的字段初始化块(Field Initializer)中,但字段初始化块不能直接使用 this 关键字。
Java 规定:
- 字段初始化块(直接在类中声明变量并赋值的地方)只能包含简单赋值语句,不能包含
this、super等引用,因为此时对象尚未完全初始化(所以也不能引用)。 this关键字只能在:- 实例方法内部(包括构造方法)。
- 实例初始化块(
{ ... })中(代码块)。
合法的修改方式
1. 使用实例初始化块
public class User extends Person {
// 实例初始化块:使用 this 合法
{
this.country = "子类的";
}
// 其他代码...
}2. 在构造方法中赋值
public class User extends Person {
// 构造方法:使用 this 合法
public User() {
this.country = "子类的";
}
// 其他代码...
}3. 直接赋值(不使用 this)
public class User extends Person {
// 直接赋值:无需 this
country = "子类的";
// 其他代码...
}修正后的完整代码
package UserPack;
import PersonPack.Person;
public class User extends Person {
// 方式1:直接赋值(无需 this)
country = "子类的";
// 方式2:实例初始化块(使用 this 合法)
{
this.country = "子类的";
}
// 方式3:构造方法(使用 this 合法)
public User() {
this.country = "子类的";
}
public void show3() {
User u1 = new User();
System.out.println("子类中通过子类实例调用父类Protected成员和方法");
System.out.println(u1.country);
u1.show1();
}
}为什么字段初始化块中不能使用 this?
- 对象初始化顺序:字段初始化块在对象创建时执行,但此时对象尚未完全构造完成(构造方法还未执行)。
- this 的语义:
this代表当前已初始化的对象,而在字段初始化块中,对象状态可能不完整,因此禁止使用this以避免潜在错误。
Java 多态
对于存在继承关系的类,或者是接口与接口的实现类(接口多态),我们通过在定义方法时,设置形参为他们的父类或者接口类,当传入对应的子类或者实现类时,可以依据传入的对象类型不同(此时发生对象的向上转型,向上转型后无法使用子类专有的属性,需再进行向下转型然后才能使用),来实现同一方法依据传入参数对象类型不同实现不同功能的作用(与方法重载不同,方法重载是依据参数类型返回类型不同来区分调用哪个方法来实现功能,多态是只有一个方法,且传入参数是有可能是固定的)。这种方式叫做多态。
一、多态的核心定义
多态(Polymorphism)是面向对象三大特性(封装、继承、多态)之一,指同一行为(方法)在不同对象上表现出不同的实现形式。
简单说:“一个接口,多种实现”,即通过父类 / 接口引用指向子类 / 实现类对象,调用方法时实际执行的是子类 / 实现类的重写方法。
当一个方法的参数是接口时,可以传递接口所有实现类的对象,这种方式称之为接口多态。
二、多态的实现条件
多态的实现必须满足三个条件,缺一不可:
- 继承关系:存在父类与子类的继承(包括接口与实现类的 “实现” 关系,可视为特殊的继承);
- 方法重写:子类必须重写父类中的方法(接口的实现类必须实现接口中的抽象方法);
- 向上转型:使用父类 / 接口类型的引用指向子类 / 实现类的对象(即
父类类型 变量名 = new 子类类型();)。
三、核心机制:向上转型与方法调用
- 向上转型:子类对象赋值给父类引用,是多态的前提。
例:Animal animal = new Cat();(Animal是父类,Cat是子类)
此时,animal只能访问父类中定义的属性和方法,无法直接访问子类的特有属性或方法(如Cat的catchMouse()方法)。 - 方法调用的特殊性:
当通过父类引用调用方法时,如果该方法在子类中被重写,则实际执行的是子类的重写方法;
如果方法没有被重写,则执行父类的原有方法。
(属性不具备多态性,调用的始终是父类的属性)
属性不具备多态性
疑问1(继承中的属性隐藏)
public Fu{
String name;
}
public Zi extends Fu{
String name;
}这里子类虽然继承父类了,但是子类中有一个定义的与父类的继承来的属性一摸一样的属性name,这个name只是名称类型相同,实则与父类继承来的name不是同一个内存地址。
在这种情况下,子类中定义的name属性与父类中的name属性是两个完全独立的变量,它们拥有不同的内存地址。
这种情况称为 “属性隐藏”(Field Hiding):
- 子类中的
name会隐藏父类中同名的name属性 - 它们是两个不同的成员变量,存储在不同的内存位置
- 在子类中直接访问
name时,会访问子类自己定义的name - 如果要访问父类被隐藏的
name,需要使用super.name
可以通过一个简单的示例来验证:
public class Fu {
String name = "父类的name";
}
public class Zi extends Fu {
String name = "子类的name"; // 隐藏父类的name属性
public void printNames() {
System.out.println("子类的name: " + name); // 直接访问访问的是子类自己的name
System.out.println("父类的name: " + super.name); // 访问父类被隐藏的name
}
public static void main(String[] args) {
Zi zi = new Zi();
zi.printNames();
}
}
//运行结果如下
子类的name: 子类的name
父类的name: 父类的name这清楚地表明两个name属性是独立存在的,拥有不同的内存地址和值。
总结
属性隐藏只是对于当使用子类类型引用调用属性时(有点类似于就近原则),当使用父类引用(即便是指向子类对象)调用属性时,调用的仍是父类的属性,以及多态时直接调用也是调用的父类的属性(因为属性不具有多态性,方法才具有多态性,当需要使用子类定义的属性时需要进行向下转型来获取子类定义的属性)。
疑问2(属性没有多态性)
那为什么使用
public Fu{
String name="父";
}
public Zi extends Fu{
String name="子";
}
Fu fu=new Zi();
System.out.println(fu.name);时调用的结果等于父
因为Java 中属性(成员变量)不具备多态性,它的访问遵循 "编译期绑定" 规则,即根据声明的变量类型来决定访问哪个属性,而不是根据实际创建的对象类型。
在你的代码中:
Fu fu = new Zi(); // 声明类型是Fu,实际对象是Zi
System.out.println(fu.name); // 访问的是声明类型(Fu)的name属性虽然fu变量指向的是Zi对象,但由于变量声明为Fu类型,编译器在编译时就确定了要访问Fu类中的name属性,因此输出 "父"。
这与方法的多态性形成鲜明对比:
- 方法调用是运行期绑定(运行看右边),根据实际对象类型决定调用哪个方法
- 属性访问是编译期绑定(编译看左边),根据变量声明类型决定访问哪个属性
如果想访问子类的name属性,有两种方式:
- 将变量声明为子类类型:
Zi zi = new Zi(); System.out.println(zi.name); - 在子类中提供一个访问方法(利用方法多态性)
public class Fu {
String name = "父";
public String getName() { return name; }
}
public class Zi extends Fu {
String name = "子";
@Override
public String getName() { return name; } // 重写父类方法
}
// 此时调用方法会得到子类的name
Fu fu = new Zi();
System.out.println(fu.getName()); // 输出"子"总结
记住这个重要区别:Java 中只有方法有多态性,属性没有。
当通过父类引用调用的始终是父类的属性
疑问3(多态中对象的属性和方法调用规则)
属性没有多态性,所以当对象向上转型,也就是用父类引用调用属性时,调用的是父类的属性,调用方法时,若子类重写了,调用的是重写的方法,若没有调用的是子类继承来的方法
在 Java 中,属性和方法在向上转型时的表现确实存在差异,核心区别在于:
- 属性(成员变量):
不具备多态性,访问时遵循 "编译期类型决定" 原则。
当父类引用指向子类对象时,通过该引用访问的属性是父类中定义的属性(如果子类与父类有同名属性,会发生 "隐藏" 而非 "重写",此时调用的仍是父类的属性,即便发生属性隐藏)。 - 方法:
具备多态性,调用时遵循 "运行期类型决定" 原则。
当父类引用指向子类对象时:- 若子类重写了父类方法,调用的是子类的重写版本;
- 若子类未重写,调用的是从父类继承的方法(前提是方法可继承,即非
private)。
示例代码验证:
class Parent {
String name = "Parent属性"; // 父类属性
void print() { // 父类方法
System.out.println("Parent方法");
}
}
class Child extends Parent {
String name = "Child属性"; // 子类同名属性(隐藏父类属性)
@Override
void print() { // 重写父类方法
System.out.println("Child重写方法");
}
}
public class Main {
public static void main(String[] args) {
Parent p = new Child(); // 向上转型
System.out.println(p.name); // 访问属性:输出"Parent属性"(父类的)
p.print(); // 调用方法:输出"Child重写方法"(子类的)
}
}总结
- 属性看 "引用类型"(编译时类型),方法看 "对象类型"(运行时类型),这是 Java 多态机制的重要体现。
四、多态与方法重载的区别
你提到了多态与重载的区别,这里用表格更清晰地对比:
| 对比维度 | 多态(运行时多态) | 方法重载(编译时多态) |
|---|---|---|
| 定义 | 同一方法在不同对象上有不同实现 | 同一类中,方法名相同但参数不同 |
| 区分依据 | 方法重写 + 对象类型(运行时确定) | 参数列表(类型、个数、顺序)不同 |
| 发生时机 | 运行时(JVM 动态绑定) | 编译时(编译器静态绑定) |
| 方法名 | 相同(父类与子类重写) | 相同(同一类中) |
| 参数类型 | 方法参数类型固定(父类 / 接口类型) | 参数类型 / 个数 / 顺序不同 |
五、多态的优势
- 代码扩展性:新增子类时,无需修改使用父类引用的代码(如方法形参为父类类型),直接传入新子类对象即可;
例:定义feed(Animal animal)方法,传入Cat、Dog等子类对象均可自动适配。 - 代码灵活性:通过父类引用统一管理不同子类对象,降低代码耦合度。
- 符合 “开闭原则”:对扩展开放(新增子类),对修改关闭(不修改原有代码)。
六、示例代码
通过一个简单例子直观感受多态:
// 父类:动物
class Animal {
public void sound() {
System.out.println("动物发出声音");
}
}
// 子类:猫(重写父类方法)
class Cat extends Animal {
@Override
public void sound() {
System.out.println("猫喵喵叫");
}
// 子类特有方法
public void catchMouse() {
System.out.println("猫抓老鼠");
}
}
// 子类:狗(重写父类方法)
class Dog extends Animal {
@Override
public void sound() {
System.out.println("狗汪汪叫");
}
}
public class TestPolymorphism {
public static void main(String[] args) {
// 向上转型:父类引用指向子类对象(多态的体现)
Animal animal1 = new Cat();
Animal animal2 = new Dog();
// 调用方法时,实际执行子类的重写方法
animal1.sound(); // 输出:猫喵喵叫
animal2.sound(); // 输出:狗汪汪叫
// 无法直接调用子类特有方法,需向下转型
if (animal1 instanceof Cat) {
Cat cat = (Cat) animal1; // 向下转型
cat.catchMouse(); // 输出:猫抓老鼠
}
}
}总结
多态的核心是 “父类引用指向子类对象,调用重写方法时动态绑定到子类实现”,它通过向上转型隐藏了具体的子类类型,让代码更通用、更易扩展。理解多态是掌握 Java 面向对象编程的关键一步,尤其是在框架开发(如 Spring 的依赖注入)中,多态的应用极为广泛。
抽象类
- 抽象类:如果一个类中存在抽象方法,那么该类就必须声明为抽象类
- 抽象方法:将共性的行为(方法)抽取到父类之后。由于每一个子类执行的内容是不一样所以,在父类中不能确定具体的方法体该方法就可以定义为抽象方法。抽象方法没有方法体,子类继承抽象类时必须重写抽象方法
抽象类中可以有属性值,也可以有抽象方法,或者是非抽象方法
抽象类和抽象方法的定义格式
- 抽象方法的定义格式:public abstract 返回值类型方法名(参数列表);
- 抽象类的定义格式:public abstract class 类名
抽象类和抽象方法的注意事项
抽象类不能实例化
抽象类中不一定有抽象方法,有抽象方法的类一定是抽象类
可以有构造方法构造方法的使用场景为:当抽象类中有抽象方法的同时还有一些成员属性,抽象类不能创建实例对象,就需要子类来继承抽象类,此时就类似于父类与子类的关系,当子类要对抽象类中的属性赋值时,就需要通过父类(抽象类中的构造)的构造方法来进行赋值。因为:谁定义的成员,谁就掌控访问权;其他类(包括子类)即使 “持有” 这个成员,也不能越权访问。总结:当创建子类对象的时,给继承来的属性进行赋值的。
抽象类的子类要么重写抽象类中的所有抽象方法要么是抽象类
接口
接口的定义和使用
- 接口用关键字
interface来定义
public interface 接口名{
}- 接口不能实例化
- 接口和类之间是实现关系,通过
implements关键字表示
public class 类名 implements 接口名{
}接口的子类 (实现类)要么重写接口中的所有抽象方法要么是抽象类
注意1:接口和类的实现关系,可以单实现,也可以多实现。
public class 类名 implements 接口名1,接口名{
}- 注意2:实现类还可以在继承一个类的同时实现多个接口。
public class 类名 extends 父类 implements 接口名1,接口名{
}接口中成员的特点
成员变量
- 只能是常量
- 默认修饰符:public static final
- 公有的静态最终常量,final 表示不能修改,static 和 public 方便调用
构造方法
接口中没有变量都是常量,而且接口不能创建实例,所以不需要构造方法。
成员方法
- 只能是抽象方法
- 默认修饰符:public abstract
- 公共的抽象方法
- JDK7以前:接口中只能定义抽象方法。
- JDK8的新特性:接口中可以定义有方法体的方法。
- JDK9的新特性:接口中可以定义私有方法。
JDK8以后接口中新增的方法
默认方法
允许在接口中定义默认方法,需要使用关键字default修饰
作用:解决接口升级的问题,当接口进行升级时(添加新功能),如果还是抽象方法,那其他的实现该接口的子类就必须重写这个方法,否则会报错,jdk8之后添加了默认方法,可以有方法体,当添加默认方法后,所有的实现该接口的子类都可以调用该方法,即便子类没有重写该默认方法(也不会报错),当默认方法不符合子类的需求时,此时需对默认方法再进行重写。
接口中默认方法的定义格式:
- 格式:public default 返回值类型方法名(参数列表)
- 范例:public default void show()
接口中默认方法的注意事项:
- 默认方法不是抽象方法,所以不强制被重写。但是如果被重写,重写的时候去掉default关键字
- public可以省略,default不能省略
- 如果实现了多个接口,多个接口中存在相同名字的默认方法,子类就必须对该方法进行重写,不进行重写,系统不知道要调用哪个接口里面的方法(因为名字相同)然后就报错,所以必须进行重写,然后调用时就调用重写后的方法。
静态方法
允许在接口中定义定义静态方法,需要用static修饰
接口中静态方法的定义格式:
- 格式:public static 返回值类型方法名(参数列表)
- 范例:public static void show()
接口中静态方法的注意事项:
- 静态方法只能通过接口名调用,不能通过实现类名或者对象名调用
- 若在实现类中写了一个相同名字的静态方法,不是重写(子类把从父类继承下来的虚方法表里面的方法进行覆盖了,这才叫重写,静态方法进不到虚方法表中),此时接口名调用的是接口的静态方法,实现类调用的是实现类的静态方法。
public可以省略,static不能省略
JDK9新增的方法
私有方法
接口中私有方法的定义格式:
普通私有方法,为接口中的默认方法服务(抽象方法没有方法体自然也不能调用其他方法),同时这个方法不需要被外界调用,设为私有。
- 格式1:private 返回值类型方法名(参数列表)
- 范例1: private void show( )
静态私有方法,为接口中的静态方法服务(抽象方法没有方法体自然也不能调用其他方法,静态方法只能调用静态方法),同时这个方法不需要被外界调用,设为私有。
- 格式2:private static返回值类型方法名(参数列表)
- 范例2: private static void method( )
接口和类之间的关系
- 类和类的关系继承关系,只能单继承,不能多继承,但是可以多层继承
- 类和接口的关系实现关系,可以单实现,也可以多实现,还可以在继承一个类的同时实现多个接口。当实现的多个接口中有相同名字的抽象方法时,只需要重写一次,它们会合并为同一个方法。
- 接口和接口的关系继承关系,可以单继承,也可以多继承。当子类实现一个有多继承的接口时,就要把所有的接口中的抽象方法全部重写。
适配器设计模式
一个接口中抽象方法过多,但是我只要使用其中一部分的时候,就可以适配器设计模式。
书写步骤:
- 编写中间类XXXAdapter,实现对应的接口
- 对接口中的抽象方法进行空实现
- 让真正的实现类继承中间类,并重写需要用的方法
- 为了避免其他类创建适配器类的对象(这个类只是作为一个转换过渡作用,创建对象没有意义),中间的适配器类用
abstract进行修饰,抽象类不能创建实例对象。
接口与抽象类的区别
抽象类
抽象类是对于子类中的一些共性进行抽取得来的,但是抽象类不是简单的像上述学习对象时的抽取。
一些子类中对于同一行为的表现是不一样的,例如动物类是父类,猫狗是子类,对于eat吃猫狗是不一样的,但是drink喝这一行为是一样的,但是eat吃drink喝,这两个行为确实是子类共有的行为(方法),可以抽取到父类中,但是在父类中方法名是固定的,二者得实际行为却不一样,所以无法确定写哪种行为(行为一样,行为的具体表现形式不一样,方法一样,方法内部内容不一样)。
直接随便写一个,然后后续子类继承的时候再进行重写覆盖确实可以解决这个问题,但是若是开发人员粗心没有进行重写,也不会报错,此时就可能出现隐藏的错误。
同时也可以不进行抽取,既然不一样那我就在每一个子类中就进行编写它对应的行为就可以了,反正抽取成抽象类时都需要再重新写,但是这样会出现在开发项目时,每个人对于子类的同一行为的书写方式不一样,调用时很不方便,每次都要看一下源码,例如游泳这一功能有的是swim有的是swmming有的是sw,而抽取成抽象类可以同一书写格式,同一行为(功能)名字都一样。调用时比较方便。
所以采用把这些虽然是共性的行为(方法),但是行为(方法)内部表现形式却不一样的(也就是在父类中不能确定方法内部要写什么功能,但是这个功能又是子类共有的),都作为抽象方法抽取到父类中,抽象方法在子类进行继承时必须进行重写,否则报错,此时父类也就成了抽象类(有抽象方法的类一定是抽象类)
接口
接口则是对于一些特定功能的抽取,即这个功能不是所有子类都有的,也就不能抽取到父类中,因为子类继承父类会把这个不需要的也继承过来,加大内存开销。
采用把这个功能(行为)抽取为一个接口,在里面写上相应的功能,当一些子类拥有这个功能时,就直接实现这个接口,作为这个接口的实现类即可(implements来实现 ) 。
接口中的方法基本全为抽象方法,也可以实现统一功能名字的作用(实际上也是抽象方法的作用)。
内部类
什么是内部类
类的五大成员:
属性、方法、构造方法、代码块、内部类
在一个类的里面,再定义一个类。举例:在A类的内部定义B类,B类就被称为内部类
内部类表示的事物是外部类的一部分,内部类单独出现没有任何意义
内部类的访问特点
- 内部类可以直接访问外部类的成员,包括私有
- 外部类要访问内部类的成员,必须创建对象
内部类的分类
成员内部类
成员内部类的代码如何书写
- 写在成员位置的,属于外部类的成员。
- 成员内部类可以被一些修饰符所修饰,比如:private,默认,protected,public,static 等
- 在成员内部类里面,JDK16之前不能定义静态变量,JDK16开始才可以定义静态变量。
如何创建成员内部类的对象
方式一
在外部类中编写方法,对外提供内部类的对象(当内部类被private修饰时)。
方式二
格式:外部类名.内部类名 对象名 = 外部类对象.内部类对象;
范例:Outer.Inner oi = new Outer().new Inner();
成员内部类如何获取外部类的成员变量
- 变量不重名时:直接调用即可
- 外部类成员变量和内部类成员变量重名时:在编译时内部类中会自动创建一个隐含的外部类变量
Outer.this来存储外部类对象的地址值,当我们需要调用外部类变量的时候,可以用Outer.this.成员变量的形式来进行调用
当外部类与内部类(尤其是非静态内部类,静态内部类不依赖外部类实例,不存在此问题)的成员变量重名时,Java 会通过「作用域优先级」和「隐含引用 Outer.this」来解决变量冲突,以下是对这一机制的详细拆解和补充说明:
一、核心原则:作用域就近优先
当在内部类中直接使用 “重名变量” 时,Java 编译器会遵循「就近原则」—— 优先访问内部类自身的成员变量,而非外部类的同名变量。
这是导致 “需要显式调用外部类变量” 的根本原因,示例如下:
class Outer {
// 外部类的重名变量
String name = "外部类的name";
// 非静态内部类
class Inner {
// 内部类的重名变量
String name = "内部类的name";
public void printName() {
// 就近原则:直接访问内部类的name
System.out.println(name); // 输出:内部类的name
}
}
public static void main(String[] args) {
Outer outer = new Outer();
Inner inner = outer.new Inner();
inner.printName();
}
}二、关键机制:隐含引用 Outer.this
为了能在内部类中访问外部类的同名变量,Java 编译器在非静态内部类实例化时,会自动为其创建一个隐含的外部类引用—— Outer.this(Outer 是外部类的类名)。
这个引用的本质是:存储了当前内部类实例所依赖的「外部类实例地址」,通过它可以明确指定访问 “外部类的成员”(包括变量和方法)。
三、显式调用外部类变量的两种场景
根据变量的「修饰符」(普通成员变量 / 静态成员变量),调用外部类变量的方式略有区别:
| 变量类型 | 调用方式 | 说明 |
|---|---|---|
| 外部类普通成员变量 | Outer.this.成员变量名 | 必须通过 Outer.this 显式指定,因为普通变量依赖外部类实例 |
| 外部类静态成员变量 | Outer.成员变量名 | 静态变量属于类本身,无需依赖实例,直接用「外部类名。变量名」即可(即使重名) |
场景 1:访问外部类普通成员变量(最常见)
修改上述示例,通过 Outer.this 调用外部类的 name:
class Outer {
String name = "外部类的name";
class Inner {
String name = "内部类的name";
public void printName() {
System.out.println(name); // 就近原则:内部类的name → 内部类的name
System.out.println(Outer.this.name); // 显式指定:外部类的name → 外部类的name
}
}
public static void main(String[] args) {
Outer outer = new Outer();
Inner inner = outer.new Inner();
inner.printName();
// 输出结果:
// 内部类的name
// 外部类的name
}
}场景 2:访问外部类静态成员变量
如果外部类的变量是 static(静态),即使与内部类重名,也无需 Outer.this,直接用「外部类名。变量名」即可(因为静态变量不依赖实例):
class Outer {
// 外部类静态成员变量
static String staticName = "外部类的staticName";
class Inner {
// 内部类重名变量(非静态)
String staticName = "内部类的staticName";
public void printStaticName() {
System.out.println(staticName); // 内部类的staticName(就近)
System.out.println(Outer.staticName); // 外部类的staticName(静态变量用类名调用)
}
}
public static void main(String[] args) {
Outer outer = new Outer();
Inner inner = outer.new Inner();
inner.printStaticName();
// 输出结果:
// 内部类的staticName
// 外部类的staticName
}
}四、注意:静态内部类无 Outer.this
需要特别提醒:静态内部类(用 static 修饰的内部类) 不依赖外部类实例,因此编译器不会为其创建 Outer.this 隐含引用。
如果静态内部类与外部类变量重名,访问外部类变量的规则如下:
- 外部类普通成员变量:静态内部类无法直接访问(因为普通变量依赖外部类实例,而静态内部类不持有实例),必须手动传入外部类实例才能访问;
- 外部类静态成员变量:直接用
Outer.变量名访问即可。
示例:
class Outer {
String normalName = "外部类普通变量"; // 普通成员变量
static String staticName = "外部类静态变量"; // 静态成员变量
// 静态内部类
static class StaticInner {
String normalName = "静态内部类普通变量";
String staticName = "静态内部类静态变量";
public void print() {
System.out.println(normalName); // 静态内部类普通变量(就近)
// System.out.println(Outer.this.normalName); // 报错!静态内部类没有Outer.this
System.out.println(staticName); // 静态内部类静态变量(就近)
System.out.println(Outer.staticName); // 外部类静态变量(正确)
}
// 若要访问外部类普通变量,需手动传入外部类实例
public void printOuterNormal(Outer outer) {
System.out.println(outer.normalName); // 外部类普通变量(通过传入的实例访问)
}
}
public static void main(String[] args) {
// 静态内部类无需外部类实例,直接new
StaticInner inner = new StaticInner();
inner.print();
inner.printOuterNormal(new Outer()); // 传入外部类实例访问其普通变量
}
}总结
- 非静态内部类与外部类变量重名时:
- 直接用变量名 → 访问内部类变量(就近原则);
- 用
Outer.this.变量名→ 访问外部类普通成员变量; - 用
Outer.变量名→ 访问外部类静态成员变量。
- 静态内部类与外部类变量重名时:
- 无
Outer.this,需通过「外部类名。静态变量名」访问外部类静态变量; - 访问外部类普通变量需手动传入外部类实例。
- 无
这一机制的核心是 Java 对 “作用域” 和 “实例依赖关系” 的严格区分,Outer.this 本质是编译器为非静态内部类自动生成的、关联外部类实例的 “桥梁”。
静态内部类
静态内部类是成员内部类中的一种特殊情况。用static修饰成员内部类即可得到一个静态内部类。
静态内部类只能访问外部类中的静态变量和静态方法(静态只能直接访问静态),如果想要访问非静态的需要创建对象。
创建静态内部类对象的格式:
外部类名.内部类名 对象名 = new 外部类名.内部类名( );
调用内部类中非静态方法的格式:
先创建对象,用对象调用。
调用内部类中静态方法的格式:
外部类名.内部类名.方法名( );
局部内部类
- 将内部类定义在方法里面就叫做局部内部类,类似于方法里面的局部变量。
- 外界是无法直接使用,需要在方法内部创建对象并使用。
- 该类可以直接访问外部类的成员,也可以访问方法内的局部变量。
匿名内部类
什么是匿名内部类
没有名字的类叫匿名内部类
匿名内部类的格式
new 继承的类名或者实现的接口名() {
重写方法;
};格式的细节
上述匿名内部类的格式其实是一个匿名内部类的对象,真正的类体是大括号{ }中的内容,前面的new和后面的( );是创建对象的格式,然后匿名内部类没有名字,new 和( )中间的部分是这个匿名内部类所继承或者实现的接口的类名。
整体就是一个类的子类对象或者接口的实现类对象
使用场景
当方法的参数是接口或者类时:
- 以接口为例,可以传递这个接口的实现类对象
- 如果实现类只要使用一次,就可以用匿名内部类简化代码。(直接传递一个匿名内部类的对象)
实例
//不使用匿名内部类
// 定义完接口还要单独定义实现类
interface Swim( ) {
public void swimming();
}
class TempSwim implements Swim {
@Override
public void swimming( ) {
System.out.println("正在游泳~~~");
}
}
// 然后调用方法
method(new TempSwim( ) );
//使用匿名内部类简化,只需定义一个接口,不需单独定义实现类
interface Swim( ){
public void swimming( );
}
method ( new Swim( ) ){
@Override
public void swimming( ){
System.out.println("正在游泳~~~");
}
}
);Lambda 表达式
简化匿名内部类的书写,不写类名,不创建对象,只要函数
格式:()->{ }
函数式编程
函数式编程(Functionalprogramming)是一种思想特点。
- 面向对象编程思想:先找对象,让对象做事情。
- 函数式编程思想:函数式编程思想,忽略面向对象的复杂语法,强调做什么,而不是谁去做。
而我们要学习的Lambda表达式就是函数式思想的体现。
Lambda 表达式的标准格式
Lambda表达式是JDK8开始后的一种新语法形式
( ) ->{
}
// ( )对应的是方法的形参,参数列表
// -> 是固定格式没有实际含义后面指向的大括号{ }是方法的方法体简化改写匿名内部类实例如下:
//此处调用实现 Comparator<Integer> 接口的匿名内部类作为 sort 方法的第二个形参传入,改写之前如下
Arrays.sort(arr, new Comparator<Integer>( ) {
@Override
public int compare(Integer ol, Integer o2){
return o1 - 02;
}
});
//改写之后如下,直接把方法的形参列表以及方法体传进去即可,不需要创建匿名内部类对象
Arrays.sort(arr, (Integer ol, Integer o2) -> {
return o1 - 02;
}
);注意
Lambda表达式可以用来简化匿名内部类的书写Lambda表达式只能简化函数式接口的匿名内部类的写法- 函数式接口:
有且仅有一个抽象方法的接口叫做函数式接口,接口上方可以加@Functionallnterfacee注解
Lambda 表达式的省略写法
- 参数类型可以省略不写。
- 如果只有一个参数,参数类型可以省略,同时()也可以省略。
- 如果Lambda表达式的方法体只有一行,大括号,分号,return可以省略不写,需要同时省略。
原格式
Arrays.sort(arr, (Integer ol, Integer o2) -> {
return o1 - 02;
}
);省略写法
//首先参数类型可以省略,传递参数时,会把参数依次传入,参数类型可以不写
Arrays.sort(arr, ( ol, o2) -> {
return o1 - o2;
}
);
//当只有一个参数时,小括号也可以省略
Arrays.sort(arr, ol -> {
return o1 - o2;
}
);
//如果Lambda表达式的方法体只有一行,大括号,分号,return可以省略不写,需要同时省略
Arrays.sort(arr, (ol, o2) -> o1 - o2);三,关于Protected修饰符的注意事项:
1:首先明确一下被Protected修饰符修饰的变量的调用规则:
Java 中protected成员的访问规则是:
(1)同包内:任何类都能访问
(2)不同包的子类:只能在子类内部访问父类的protected方法,并且必须通过子类类型的实例调用
父类:
public class Person {
protected String country="父类Protected类型变量"; //定义父类受保护的变量
//定义父类受保护的方法
protected void show1(){
System.out.println("这是父类中的Protected方法");
}2,在子类中对于继承来的Protected类型的对象和方法:
**注意:通过子类实例调用并不意味着,可以直接在子类的字段代码块中创建对象**,
会引起无限递归创建该类对象,造成栈溢出
public class User extends Person {
//下面尝试在子类中使用子类实例来调用父类的Protected类型的变量或者方法看看能不能成功
User u1=new User();
//不能在类的字段代码块中定义非静态类的该类的实例对象,会引起无限递归创建该类对象,造成栈溢出
this.country="子类的";
//错误原因, Java 不允许在类的字段初始化块或方法外部直接使用 this 关键字
public void show3(){
User u1=new User();
this.country="子类的country变量";
System.out.println(country);
System.out.println("子类中通过子类实例调用父类Protected成员和方法");
//通过子类实例调用父类的Protected变量
System.out.println( u1.country);
//通过子类实例调用父类的Protected方法
u1.show1();
}
}3,若要调用继承来的Protected类的变量和方法:
在方法中定义子类的实例,来调用父类中的Protected类的变量和方法:
public class User extends Person {
//下面尝试在子类中使用子类实例来调用父类的Protected类型的变量或者方法看看能不能成功
User u1=new User();
//不能在类的字段代码块中定义非静态类的该类的实例对象,会引起无限递归创建该类对象,造成栈溢出
this.country="子类的";
//错误原因, Java 不允许在类的字段初始化块或方法外部直接使用 this 关键字
public void show3(){
User u1=new User();
this.country="子类的country变量"; //调用子类中继承来的Protected变量
System.out.println(country);
System.out.println("子类中通过子类实例调用父类Protected成员和方法如下");
//通过子类实例调用父类的Protected变量
System.out.println( u1.country);
//通过子类实例调用父类的Protected方法
u1.show1();}
}测试类如下:
public class ProtectedTest {
public static void main(String[] args){
User us1=new User();
//尝试在不同的包中,利用子类的实例对象来访问父类中的Protected类型的变量与方法
//在不同的包中利用子类访问父类的Protected类型方法失败
us1.show1();
//父类的保护变量不能通过子类的实例在其他包中访问
System.out.println(us1.country);
//通过在子类中的方法中利用子类实例调用父类deprotected类的变量和方法
us1.show3();
}控制台结果如下:
子类的country变量
子类中通过子类实例调用父类Protected成员和方法如下
父类Protected类型变量
这是父类中的Protected方法
Process finished with exit code 0四:关于Java中对于多态的一些注意事项:
1:Java中的多态是通过采用父类类型的变量来接收子类类型的变量(利用了类的向上转型),从而实现了对一个函数传入不同类的实例对象,都能正常执行且结果各不相同的需求
调用时遵循的规则:
1.多态的前提:
- 有继承关系
- 有父类引用指向子类对象 Fu f = new Zi ();
- 有方法重写 好处:使用父类型作为参数,可以接收所有子类对象
(1)变量:编译看左边,运行看左边
- 调用成员变量:编译看左边,运行也看左边
- 编译看左边:javac编译代码的时候,会看左边的父类中有没有这个变量,如果有,编译成功,如果没有编译失败。
- 运行也看左边:javac运行代码的时候,实际获取的就是左边父类中成员变量的值
(2)方法:编译看左边,运行看右边
- 调用成员方法:编译看左边,运行看右边
- 编译看左边:javac编译代码的时候,会看左边的父类中有没有这个方法,如果有,编译成功,如果没有编译失败。
如果未被重写,则调用时调用的是父类中的方法!
- 运行看右边:java运行代码的时候,实际上运行的是子类中的方法。(如果未被重写,则调用时调用的是父类中的方法!)
注意:
成员变量:在子类的对象中,会把父类的成员变量也继承下的。利用多态时是用的父类类型实例所以会先从父类中找。父:name ,然后才是,子:name
成员方法:如果子类对方法进行了重写,那么在虚方法表中是会把父类的方法进行覆盖的。所以调用的方法是子类中重写过的方法(方便对父类进行功能扩充)
未重写时也不会出错,未重写就未覆盖父类虚方法表中的方法,调用时还是调用父类中的方法
2,多态的弊端:
父类不能调用子类中特有的属性和方法,所以利用多态时会造成子类中的一些属性或者方法的丢失,此时需要利用向下转型来获取丢失的变量。
3.接口类多态:
无论是父类引用指向子类对象,还是接口引用指向实现类对象,都能够实现多态。比如:
// 父类与子类的多态
Animal animal = new Dog();
// 接口与实现类的多态
List list = new ArrayList();实现多态的方式基本上是一样的,一个是父类接收子类对象,一个是接口类接收对应的实现类对象。
若用接口类对象接收接口对应的实现类对象,即是一个接口类多态:
调用时遵循以下规则:
调用的函数全是实现类中的函数,(即对接口中的所有抽象方法实现重写后的方法,若是普通多态,若重写了父类中的方法,调用的就是重写之后的方法,若未重写,则调用的是父类中原本的方法。)
五,匿名内部类
1.什么是匿名内部类?
隐藏了名字的内部类,可以写在成员位置,也可以写在局部位置。
2.匿名内部类的格式?
new 类名或者接口名(){
重写方法;
};
3.格式的细节
包含了继承或实现,方法重写,创建对象。
整体就是一个类的子类对象或者接口的实现类对象
4.使用场景
当方法的参数是接口或者类时,
以接口为例,可以传递这个接口的实现类对象,
如果实现类只要使用一次,就可以用匿名内部类简化代码。
实例:
//不使用匿名内部类
// 单独定义实现类
class TempSwim implements Swim {
@Override
public void swimming() {
System.out.println("正在游泳~~~");
}
}
// 调用方法
method(new TempSwim());
//使用匿名内部类简化
method ( new Swim() ){
@Override
public void swimming(){
System.out.println("正在游泳~~~");
}
}
);
interface Swim(){
public void swimming();
}new Swim() 并不是表示 "匿名内部类的名字",因为匿名内部类本身是没有名字的。这里的 Swim 是被实现的接口(或继承的类),new Swim() { ... } 整体表示:
- 创建一个实现了
Swim接口的匿名类 - 同时实例化这个匿名类的对象
具体来说:
Swim是接口(或抽象类),匿名内部类实现了它的抽象方法swimming(){}中是这个匿名类的类体,包含了方法实现- 整个表达式的结果是这个匿名类的一个实例对象
- 这个对象被直接作为参数传递给
method()方法
这种写法的优势在于:当某个接口 / 抽象类的实现只需要使用一次时,无需单独定义一个命名类,减少了代码冗余,让逻辑更紧凑。
六,Object中clone方法的使用
在 Java 中,Object 类的 clone() 方法用于创建并返回当前对象的一个副本。以下是关于该方法的详细说明和使用指南:
1. 基本原理
- 浅拷贝(Shallow Copy)
Object.clone()默认实现浅拷贝,即:
在 Java 中,浅拷贝对基本数据类型的复制会创建独立的内存空间,改动其中一个不会影响另一个。具体分析如下:
仅复制对象的一层属性。
基本数据类型**(如 int, boolean, double 等):直接复制值,新对象和原对象的基本类型字段占用**不同内存空间。
新对象和原对象占用同一内存空间,改动其中一个另一个也会同步改动。
- **基本数据类型**(如 `int`, `boolean`)会复制值。
- **引用类型**(如数组、对象)只会复制引用,而不复制对象本身(多个引用指向同一个对象)。
- 深拷贝(Deep Copy):若需要完全独立的对象,需在子类中重写
clone()方法并手动复制引用类型的字段。
无论浅拷贝还是深拷贝都需实现Cloneable接口
Cloneable 接口的作用:
- 未实现
Cloneable时,调用clone()会抛出CloneNotSupportedException。 Cloneable接口本身无方法,仅作为标记存在。
2,浅克隆步骤:
步骤 1:实现 Cloneable 接口
Cloneable 是一个标记接口(无方法),用于表明该类支持 clone()。若未实现此接口,调用 clone() 会抛出 CloneNotSupportedException异常。
步骤 2:重写 clone() 方法
在子类中重写 clone(),并将访问修饰符改为 public(原 Object.clone() 为 protected)。
注意:若不修改原本的Protected访问权限,若是跟父类Object在同一个包下,则可以访问,但是我们的代码不可能写到java.lang包下,所以不能直接在测试类中调用
解决办法:在需要用到克隆的子类中对该方法进行重写,此时又分为以下几种情况:
- 未修改clone方法的Protected为public此时若Test类与该需要克隆的类在同一包下(此时已经对clone方法进行重写,并且该类实现了Cloneable接口),才能该子类实例使用该clone方法,否则报错(不在同一个包下)
- 修改clone方法的protected权限为public保证了测试类在任何位置(无论与该类在不在同一个包下)都能正常通过该子类实例使用clone方法进行克隆。
也可以换种思维来看:
在一个定义了protected变量或者方法(假设名字为A)的包下的任何类中,都可以访问该protected变量或者方法(也就是A),重写也相当于进行了一次重新定义。
步骤 3:调用 super.clone()
在重写的方法中调用 super.clone() 获取浅拷贝对象,再根据需要处理引用类型字段。
注意:****在 Java 中,Object.clone()** 方法****实际上是有函数体的**,但其实现是本地方法(Native Method),用 native 关键字修饰,因此在 Java 源代码中看不到具体实现。以下是详细解释:
1. Object.clone() 的声明
在 Object 类中,clone() 方法的声明如下:
protected native Object clone() throws CloneNotSupportedException;- native 关键字:表示该方法的实现由底层的本地代码(如 C/C++)完成,而非 Java 代码。
- 函数体位置:具体实现位于 JVM 内部,Java 源代码中仅保留方法签名。
2. 为什么使用本地方法?
2.1 内存操作的底层需求
clone() 需要直接操作对象的内存布局,复制对象的所有字段(包括私有字段),这在 Java 层面难以高效实现,因此依赖本地代码。
2.2 平台相关性
对象的内存结构和复制机制与操作系统、硬件架构相关,本地方法能提供更好的跨平台兼容性。
3. 本地方法的工作原理
- Java 层:定义方法签名,用
native标记。 - 本地层:JVM 用 C/C++ 实现具体逻辑,直接操作内存。
- JNI(Java Native Interface):作为桥梁,连接 Java 代码和本地代码。
4. 如何查看本地方法的实现?
- OpenJDK 源码:可在 OpenJDK 项目中查看 C/C++ 实现(如
hotspot/src/share/prims目录)。 - 调试工具:通过调试器(如 GDB)可跟踪本地方法的执行。
浅克隆时会出现的一些异常以及处理方法:
- 要在使用克隆的方法上加上 throws CloneNotSupportedException 来抛出异常:
public static void main(String[] args) throws CloneNotSupportedException**2.克隆时虽然是用同类型的类的引用,但是还是要对克隆出来的对象进行强制类型转换(实际上也是为了指明克隆的对象类型是什么)**
Student s2= (Student) s1.clone();3.深克隆实现方法:
3.1:自己重写clone方法:
对基本数据类型采用浅克隆方法实现克隆,Object中的clone(也就是super.clone() )
对引用类型的数据,进行自己创建(new一个同类型的变量),然后替换掉克隆出来的对象中引用类型数据的指向就可以了。
3.2:采用JSON方法:
- 先把需要克隆的对象转换为JSON字符串。
- 再把字符串变回对象。
//第三方的工具
//1.第三方写的代码导入到项目中
//2.编写代码
Gson gson = new Gson();
//把对象变成一个字符串
String s = gson.toJson(u1);
//再把字符串变回对象就可以了
User user = gson.fromJson(s, User.class);
//打印对象
System.out.println(user);七,Objects中的方法:
1.equals:先做非空判断,然后比较两个对象是否相等,
public static boolean equals(object a,object b)
//源码如下:
public static boolean equals(object a, Object b) {
return (a == b) || (a != null && a.equals(b));
}具体使用代码实例:
boolean result=Objects.equals(s1,s2);
System.out.println(result);
//细节:
//1.方法的底层会判断s1是否为null,如果为null,直接返回false
//2.如果s1不为null,那么就利用s1再次调用equals方法
//3.此时s1是Student类型,所以最终还是会调用student中的equals方法。
//4.如果没有重写,比较地址值,如果重写了,就比较属性值。2.isNull:判断变量是否为空:
- 为空返回true
- 不为空返回false
public static boolean isNull(Object obj)3.nonNull:判断变量是否不为空:
- 为空返回false
- 不为空返回true
public static boolean nonNull(Object obj)八,对java中的逻辑运算符 &&,||的补充:
1.逻辑与短路运算符(&&)
&&用于对两个布尔表达式进行逻辑与运算,只有当两个表达式的结果都为 true 时,整个运算结果才为 true,否则为 false。并且它具有短路特性,也就是说,一旦左边的表达式结果为 false,就不会再去计算右边的表达式。
2.逻辑或短路运算符(||)
||用于对两个布尔表达式进行逻辑或运算,只要两个表达式中有一个结果为 true,整个运算结果就为 true,只有当两个表达式结果都为 false 时,运算结果才为 false。它也具备短路特性,当左边的表达式结果为 true 时,就不会再计算右边的表达式。
九,BigDecimal:
1.BigDecimal的作用是什么?
表示较大的小数和解决小数运算精度失真问题。
2.BigDecimal的对象获取:
构造方法获取BigDecimal对象:
1.通过传递double类型的小数来创建对象
细节:
这种方式有可能是不精确的,所以不建议使用
public BigDecimal(double val)2.通过传递字符串表示的小数来创建对象
public BigDecimal(String val)静态方法获取BigDecimal对象:
1.如果要表示的数字不大,没有超出double的取值范围,建议使用静态方法
2.如果要表示的数字比较大,超出了double的取值范围,建议使用构造方法
3.如果我们传递的是e~10之间的整数,包含θ,包含1θ,那么方法会返回已经创建好的对象,不会重新new
public static BigDecimal valueOf(double val)3.常见操作
加:add
减:subtract
乘:multiply
除:divide(四舍五入:RoundingMode.HALF_UP)
十,正则表达式:
1.字符类(只匹配一个字符)
[abc]只能是a,b,或c
[a-zA-Z]: a到zA到z,包括(范围)
[a-d[m-p]]: a到d,或m到p
[a-z&&[def]: a-z和def的交集。为:d,e,f
[a-z&&[<bc]]: a-z和非bc的交集。(等同于[ad-z])[a-z&&[^m-p](除了m到p)] : a到z和除了m到p的交集(等同于[a-lq-z])
2.预定义字符(只匹配一个字符)
. :任何字符
\d :一个数字:[0-9]
\D :非数字:
\s :一个空白字符:[\t\n\xOB\f\r]
\S :非空白字符
\w :[a-zA-Z_0-9]英文、数字、下划线
\W :一个非单词字符
3.数量词
X? X,一次或0次
X* X,零次或多次
X+ X,一次或多次
X{n} X, 正好n次
X{n, } X,至少n次
X{n,m} X, 至少n但不超过m次
4.转义字符
转义字符改变后面那个字符原本的含义
练习:以字符串的形式打印一个双引号:
" 在Java中表示字符串的开头或者结尾
此时\表示转义字符,改变了后面那个双引号原本的含义
把他变成了一个普普通通的双引号而已。
System.out.println("\"");5.注意事项
如果要求两个范围的交集,那么需要写符号&&
如果写成了一个&,那么此时&表示就不是交集了,而是一个简简单单的&符号
一个正则表达式只对应一个字符,若要对应多个,需要用 '&&' , '||' 来连接各个表达式,或者用结合数量词来表示
因为 \ 在java中表示为转义字符,具有转义功能。而预定义字符前自带有一个无任何意义的 \ 符号所以在使用预定义字符时,要在预定义字符的 \ 前面再加上一个 \ 作为转义字符把预定义字符前的 \ 转换为无意义的 \ 字符。(若不加字符 \ 则java会把预定义字符前的 \ 字符当作转义字符来使用,这样会改变预定义字符的作用!)
6.爬取时需要使用的类:
1.Pattern:表示正则表达式
Pattern对象不能去new,是通过静态方法(compile)获取的
//获取正则表达式的对象
Pattern p=Pattern.compile("正则表达式");2.Matcher:文本匹配器,作用按照正则表达式的规则去读取字符串,从头开始读取。在大串中去找符合匹配规则的子串。
//获取文本匹配器的对象
//m要在str中找符合规则p的小串
Matcher m=p.matcher(str);
//拿着文本匹配器从头开始读取,寻找是否有满足规则的子串
//如果没有,方法返回false
//如果有,返回true。在底层记录子串的起始索引和结束索引+1
boolean b=m.find();
//group方法底层会根据find方法记录的索引进行字符串的截
//subString(起始索引,结束索引);包头不包尾
//(0,4)但是不包含4索引
//会把截取的小串进行返回。
String s1 = m.group();
System.out.println(s1);
//利用循环获取
//利用find反复匹配,直到find返回false表明匹配到末尾
while(m.find()){
String s =m.group();
System.out.println(s);
}7.带条件的爬取:
需求1:爬取版本号为8,11,17的Java文本,但是只要Java,不显示版本号。
需求2:爬取版本号为8,11,17的Java文本。正确爬取结果为:Java8,Java11,Java17,Java17
需求3:爬取除了版本号为8,11,17的Java文本,
//定义正则表达式
//?理解为前面的数据Java
//=表示在Java后面要跟随的数据
//?=表示在获取的时候,只获取前半部分,不取?=后面的内容,但是他们是作为匹配条件的
//需求1
String regex1 ="Java(?=8|11|17)";
//String regex1="((?i)Java)(?=8|11|17)";中?i的作用是忽略大小写
//需求2
String regex2 ="((?i)Java)(8|11|17)";
String regex3 ="((?i)Java)(?:8|11|17)";
//?:表示获取的时候,也获取:后面的内容
//需求3
String regex4=“((?)Java)(?!8|11|17)";
//?!表示除去满足?!后面的正则表达式的字符串
Pattern p = Pattern.compile(regex);
Matcher m = p.matcher(s);
while(m.find()){
System.out.println(m.group());
}8.贪婪爬取和非贪婪爬取:
只写+和*表示贪婪匹配
+?非贪婪匹配
*?非贪婪匹配
贪婪爬取:在爬取数据的时候尽可能的多获取数据
非贪婪爬取:在爬取数据的时候尽可能的少获取数据
ab+:
贪婪爬取:abbbbbbbbbbbb
非贪婪爬取:ab
Java当中,默认的就是贪婪爬取
如果我们在数量词+或者*的后面加上问号,那么此时就是非贪婪爬取
9.正则表达式在字符串方法中的使用:
方法名说明:
public String[] matches(String regex)
//判断字符串是否满足正则表达式的规则public String replaceAll(String regex,String newStr)
//按照正则表达式的规则进行替换public String[] split(String regex)
//按照正则表达式的规则切割字符串一般参数为 regex表示该参数是一个正则表达式!
10.正则表达式的分组:
每组是有组号的,也就是序号。(用小括号括起来的是一组)
规则1:从1开始,连续不间断。
(\d+) (\d+) (\d+) ,从左往右依次是第一组,第二组,第三组
**规则2:**以左括号为基准,最左边的是第一组,其次为第二组,以此类推。
(\d+ (\d+) ) (\d+) 第一组:(\d+(\d+)) 第二组:(\d+) 第二组是在第一组里面的那个小括号
10.1捕获分组:
后续还要继续使用本组的数据
正则内部使用:\组号
正则外部使用:$组号
示例如下:
/*需求:
将字符串:我要学学编编编编程程程程程程
替换为:我要学编程*/
Stringstr="我要学学编编编编程程程程程程";
//需求:把重复的内容替换为单个的
//学学学
//编编编编编
//程程程程程程程
//(.)表示把重复内容的第一个字符看做一组
// \\1表示第一字符再次出现
// 至少一次
// $1表示把正则表达式中第一组的内容,再拿出来用
String result = str.replaceAll( regex:"(.)\\1+", replacement: "$1");
System.out.println(result);10.2非捕获分组:
分组之后不需要再用本组数据,仅仅是把数据括起来,不占用组号:
| 符号 | 含义 | 举例 |
|---|---|---|
| (?:正则) | 获取所有 | Java(? : 8 |
| (?= 正则) | 获取前面部分 | Java(? = 8 |
| (?!正则) | 获取不是指定内容的前面部分 | Java(? ! 8 |
11.关于贪婪和非贪婪匹配以及其覆盖机制:
在正则表达式 "(.)+" 中,捕获最后一个字符的原因是量词 + 的贪婪特性和捕获组的覆盖机制共同作用的结果。以下是详细解释:
1. 正则表达式拆解
(.):捕获组,匹配任意单个字符,并将其捕获到组1中。+:量词,表示前面的模式(即括号内的.)至少出现一次,且尽可能多地重复(贪婪匹配)。
2. 贪婪匹配导致的覆盖
当正则引擎处理字符串时:
- 第一次匹配:
(.)捕获第一个字符(例如a)。 - 重复匹配:由于量词
+,正则引擎会尝试匹配更多字符,每次匹配都会覆盖之前的捕获组内容。- 例如,对于字符串
"abc":- 第一次迭代:
(.)匹配a,捕获组1的值为a。 - 第二次迭代:
(.)匹配b,捕获组1的值被更新为b。 - 第三次迭代:
(.)匹配c,捕获组1的值被更新为c。
- 第一次迭代:
- 例如,对于字符串
- 最终结果:整个字符串
"abc"被匹配,但捕获组1中只保留最后一次迭代的结果(即c)。
3. 示例验证
String str = "abc";
str.replaceAll("(.)+", "$1"); // 结果为 "c"- 正则表达式
"(.)+"匹配整个字符串"abc",但捕获组1仅包含最后一个字符c。 - 替换时,
$1引用捕获组1的内容,因此整个字符串被替换为c。
**4. 对比非贪婪匹配 **"(.)+?"
即使使用非贪婪匹配 +?(尽可能少匹配),结果仍然相同:
String str = "abc";
str.replaceAll("(.)+?", "$1"); // 结果仍为 "c"- 非贪婪匹配会优先匹配最短可能的字符串(即
a),但量词+仍允许继续匹配后续字符。 - 正则引擎会尝试所有可能的匹配路径,最终选择最长的匹配(即整个字符串
"abc"),捕获组1仍被最后一个字符c覆盖。
5. 如何捕获所有字符?
如果需要捕获所有字符,应避免使用量词 + 修饰捕获组。例如:
- 正确写法:
"(.*)"(捕获整个字符串)
String str = "abc";
str.replaceAll("(.*)", "[$1]"); // 结果为 "[abc]"总结
- 贪婪特性:量词
+会尽可能多地匹配字符,导致捕获组被多次覆盖。 - 覆盖机制:每次匹配都会更新捕获组的值,最终只保留最后一次匹配的结果。
- 常见误区:误以为
"(.)+"会捕获所有字符,实际上它只捕获最后一个。
理解正则表达式的匹配逻辑和捕获组的工作原理是避免此类问题的关键。
十一,Runtime中的方法:
方法名说明:
public static Runtime getRuntime(); 当前系统的运行环境对象
public void exit(int status); 停止虚拟机
public int availableProcessors(); 获得CPU的线程数
public long maxMemory(); JVM能从系统中获取总内存大小(单位byte)
public long totalMemory(); JVM已经从系统中获取总内存大小(单位byte)
public long freeMemory(); JVM剩余内存大小(单位byte)
public Process exec(String command); 运行cmd命令十二,System中的方法:
System也是一个工具类,提供了一些与系统相关的方法
方法名说明:
public static void exit(int status) 终止当前运行的 Java 虚拟机
public static long currentTimeMillis() 返回当前系统的时间毫秒值形式
public static void arraycopy(数据源数组,起始索引,目的地数组,起始索引,拷贝个数) 数组拷贝计算机中的时间原点为: 1970年01月01日 00:00:00
中国在东八区,时间原点为: 1970年01月01日 08:00:00
十三,JDK7之前的时间类:
1.Date时间类:
Date 类是一个jDk写好的Javabean类,用来描述时间,精确到毫秒。
利用空参构造创建的对象,默认表示系统当前时间。
public Date() {
this(System.currentTimeMillis());
} //本质上还是调用System里面的currentTimeMillis方法,返回的是毫秒值形式
//1秒=1000毫秒
public class CurrentMillersTime {
public static void main(String[] args) {
Date date1=new Date();
//输出是年月日的形式是因为,Date的方法被重写了,毫秒值转换是在被重写的tostring方法中实现的
System.out.println(date1);
}
}你提到的Date类的无参构造函数确实接收的是System.currentTimeMillis()返回的毫秒值,但输出时呈现为年月日格式,这是因为 **Date对象的toString()方法被重写了 **。
当你在控制台打印Date对象时,实际上调用了其toString()方法,而该方法会将内部存储的毫秒值转换为当前时区的可读日期字符串(例如Sat May 25 2025 00:00:00 GMT+0800)。这种转换是由Date类内部实现的,而非毫秒值本身发生了变化。
利用有参构造创建的对象,表示指定的时间。
public Date(long date) {
fastTime = date;
}
//传入参数是long类型,最好在传入数据时在数据后加L标志2.SimpleDateFormat类作用:
SimpleDateFormat只能指定Jdk7中的时间类Date的格式,DateTimeFormatter主要用于对Jdk8中新增加的时间类的格式指定。
2.1格式化:把时间变成我们喜欢的格式
例如: 2025年05月25日, 2025-05-25, 2025/05/25
2.1.1空参构造SimpleDateFormate对象:
public SimpleDateFormat() //空参构造得来的是默认格式,1970年01月01日 08:00:002.1.2带参构造得来的是指定的时间格式:
格式指定方式为:
yyyy年MM月dd日' 'HH:mm:ss' 'EE a 其中的' '表示为一个空格,空格是保证输出的时候不会挤到一起
EE:表示周几,一共两个字符所以传入两个E,
a:表示是上午
指定格式的方式就是直接传入yyyy年MM月dd日' 'HH:mm:ss' 'EE 字符串即可,具体字母对应表示含义可查询API文档
public SimpleDateFormat(String pattern)
//带参构造得来的是指定格式,格式的形式依据传入的参数2.1.3无论是带参构造还是空参构造都需要用format方法把Date对象转换为对应的指定格式的字符串形式,转换完成后即可直接打印
使用实例:
//1.利用空参构造创建SimpleDateFormat对象,默认格式,默认格式为:1970/1/1 上午8:00
SimpleDateFormat sdf1 = new SimpleDateFormat();
Date d1 = new Date(0L); //创建一个Date对象,表示时间为距离时间原点为0毫秒
String str1 = sdf1.format(d1); //把日期对象转换为固定格式的字符串,方便打印
System.out.println(str1); //1970/1/1 上午8:00
//2.利用带参构造创建SimpleDateFormat对象,指定格式
SimpleDateFormat sdf2 = new SimpleDateFormat( pattern:"yyyy年MM月dd目 HH:mm:ss EE");
String str2 = sdf2.format(d1); //把时间d1转换为sdf2的格式
System.out.println(str2); //1970年01月01日 08:00:00
//课堂练习:yyyy年MM月dd日时:分:秒 星期E2.2解析:把字符串表示的时间变成Date对象
用parse方法来把字符串转换为Date对象
public Date parse(String source) //解析(字符串->日期对象)转换示例如下:
//1.定义一个字符串表示时间
String str="2023-11-11 11:11:11";
//2.利用空参构造创建SimpleDateFormat对象
//细节:
//创建对象的格式要跟字符串的格式完全一致
SimpleDateFormat sdf = new SimpleDateFormat( pattern:"yyyy-MM-dd HH:mm:ss");
Date date = sdf.parse(str); //此处注意,parse会出现异常,把异常抛出即可
//3.打印结果
System.out.print1n(date.getTime()); //169967227100e
//用Date里的getTime方法来获取时间毫秒值,即可对其进行比较,加减等操作3.Calendar日历类(用于对时间中的年,月,日,等进行单独的操作):
Calendar代表了系统当前时间的日历对象,可以单独修改、获取时间中的年,月,日
其他时间类中也有年月日时分秒,但是获取及修改不方便,日历类是把他们封装到对应数组中,便于直接对所需数据修改。
注意:Calendar是一个抽象类,不能直接创建对象。通过静态方法getInstance获取
public static Calendar getInstance() //获取当前时间的日历对象
//1.获取日历对象
//细节:Calendar是一个抽象类,不能直接new,而是通过一个静态方法获取到子类对象
//底层原理:
//会根据系统的不同时区来获取不同的日历对象。默认表示当前时间
//把会把时间中的纪元,年,月,日,时,分,秒,星期,等等的都放到一个数组当中
//java在calendar类中,把索引对应的数字都定义成常量
Calendar c =Galendar.getInstance();注意:
月份:范围0~11如果获取出来的是0.那么实际上是1月。
星期:在老外的眼里,星期日是一周中的第一天
1(星期日)2(星期一)3(星期二)4(星期三)5(星期四)6(星期五)7(星期六)
方法:setTime(Date date),把Date对象转换为Calendar对象,利用Calendar中的各种方法来实现对时间的修改
public final setTime(Date date) //给日历设置日期对象方法:get(int field)
public int get(int field) //取日期中的某个字段信息java在calendar类中,把索引对应的数字都定义成常量:选中Calendar字符,按Ctrl+B查看,以此来获取对应的
时间。
int year = c.get(Calendar.YEAR);
int month = c.get(Calendar.MONTH) + 1;
int date = c.get(Calendar.DAY_OF_MONTH);
int week = c.get(Calendar.DAY_OF_WEEK);
System.out.println(year +"," +monthdate+","+ week); //1970,1,1,5方法:set(int field,int value)
public void set(int field,int value) //修改日历的某个字段信息
//设定时间年份为2000年
c.set(Calendar.YEAR,2000);方法:add(int field,int amount)
public void add(int field,int amount) //为某个字段增加/减少指定的值
//调用方法在月份上增加一个月
c.add(Calendar.MONTH, amount: 1) ;方法:getTimeInMillis
public long getTimeInMillis() //拿到时间毫秒值方法:setTimeInMillis
public void setTimeInMillis(long millis) //给日历设置时间毫秒值十四,JDK8的时间类
Jdk7:代码麻烦,用时间类对象获取时间,计算比较都需获取毫秒值
多线程情况下,对时间的修改会出问题
Jdk8:简化操作,例如:计算时间间隔,判断...
获取的时间日期对象不可变,当对时间进行修改后,修改函数会返回一个新的时间。
1.类似于JDK7的Date类(获取当前时间,获取指定时间)
ZoneId:时区
static Set<String> getAvailableZoneIds() //获取Java中支持的所有时区
static ZoneId systemDefault() //获取系统默认时区
static ZoneId of(String zoneId) //获取一个指定时区主要用于对时间的时区的指定
Instant:时间戳
static Instant now() //获取当前时间并将其作为Instant对象(标准时间)返回(时间原点同一时区)
static Instant ofXxxx(long epochMilli) //根据(秒/毫秒/纳秒)获取Instant对象
ZonedDateTime atZone(ZoneId zone) //指定时区
boolean isXxx(Instant otherInstant) //判断时间前后的一系列的方法
Instant minusXxx(long millisToSubtract) //减少时间的一系列的方法
Instant plusXxx(long millisToSubtract) //增加时间的一系列的方法注意:
**Instant主要用于对时间的获取,获取的时间是与时间原点同一时区,**若要进行时区转换,需要用到atZone()先进行转换,转换为ZonedDateTime来进行打印(这样打印的同时会自动加上时区,切打印的是转换时区后的时间),直接打印Instant类型时间,是与时间原点同一时区;
ZonedDateTime atZone(ZoneId zone)
//获取时间的同时指定时间的时区
ZonedDateTime zoneTime=Instant.now().atZone(ZoneId.of("Asia/Shanghai"));
System.out.println(zoneTime);
//2025-07-09T17:25:32.914032200+08:00[Asia/Shanghai]该函数是Instant类中的非静态函数,需要用对象调用(Instant.now()创建一个当前时间的Instant对象),atZone中的参数类型为ZoneId类型,我们通过ZoneId中的静态函数(static ZoneId of(String zoneId) )来指定时间的时区,并传到ZonedDateTime atZone(ZoneId zone) 函数中,来确定时间的时区为哪一个,然后在该函数中对时间进行时区转换,最终得到的是转换后对应时区的时间,其类型为ZoneDateTime
ZoneDateTime:转换时区后的时间
static ZonedDateTime now() //获取当前时间的zonedDateTime对象
static ZonedDateTime ofXxxx() //获取指定时间的ZonedDateTime对象
ZonedDateTime withXxx(时间) //修改时间系列的方法,把时间修改为想要指定的值
ZonedDateTime minusXxx(时间) //减少时间系列的方法
ZonedDateTime plusXxx(时间) //增加时间系列的方法ZoneDateTime类型主要用于转换时区后的时间的打印以及修改
2.日期格式化类(类似于JDK7SimpleDateFormat,指定时间格式)
DateTimeFormatter:用于时间的格式化和解析
static DateTimeFormatter ofPattern(格式) //获取格式对象
String format(时间对象) //按照指定方式格式化,把时间转换为指定格式的字符串形式DateTimeFormatter主要用于对Jdk8中新增加的时间类的格式指定,SimpleDateFormat只能指定Jdk7中的时间类Date的格式
3.日历类(类似于JDK7的Calendar,用于对时间中的年,月,日,等进行单独的操作)
其他时间类中也有年月日时分秒,但是获取及修改不方便,日历类是把他们封装到对应数组中,便于直接对所需数据修改。
| 方法名 | 说明 |
|---|---|
| static xxx now() | 获取当前时间的对象 |
| static xxx of () | 获取指定时间的对象 |
| get开头的方法 | 获取日历中的年、月、日、时、分、秒等信息 |
| isBefore, isAfter | 比较两个 LocalDate |
| with开头的 | 修改时间系列的方法 |
| minus开头的 | 减少时间系列的方法 |
| plus开头的 | 增加时间系列的方法 |
LocalDateTime **可以转化为 **LocalDate 或者 LocalTime :
| 方法名 | 说明 |
|---|---|
| public LocalDate toLocalDate() | LocalDateTime转换成一个LocalDate对象 |
| public LocalTime toLocalTime() | LocalDateTime转换成一个LocalTime对象 |
LocalDate:年,月,日
time=2023-12-09
//JDK8中的时间对象不能改变,所以修改后的时间作为新的对象返回
LocalDate addTime=time2.plusDays(2);
addTime=time2.plusYears(1);
//若对同一个对象进行多次修改,只有最后一个修改语句可以实现
System.out.println("增加后的时间为:"+addTime);
结果为:addTime=2024-12-09LocalTime:时,分,秒
用法同LocalDate
LocalDateTime:年,月,日,时,分,秒
用法同LocalDate
4.工具类
Duration:时间间隔(秒,纳秒)
public static void main(String[] args) {
//计算时间间隔,天数,时分秒,微秒,纳秒
LocalDateTime birthday= LocalDateTime.of(2025,7,13,0,0,0,0);
LocalDateTime nowTime=LocalDateTime.now();
Duration duration=Duration.between(nowTime,birthday);
System.out.println("距离过生日还有:"+duration.toDays()+"天");
System.out.println("距离过生日还有:"+duration.toHours()+"小时");
System.out.println("距离过生日还有:"+duration.toMinutes()+"分钟");
System.out.println("距离过生日还有:"+duration.toSeconds()+"秒");
System.out.println("距离过生日还有:"+duration.toMillis()+"毫秒");
System.out.println("距离过生日还有:"+duration.toNanos()+"纳秒");
}Period:时间间隔(年,月,日)
public static void main(String[] args) {
//获取当前时间
LocalDate today=LocalDate.now();
//生日时间
LocalDate birthday=LocalDate.of(2004,7,13);
//计算出生了多久,between函数是后减前
Period period=Period.between(birthday,today);
System.out.println("出生了已经有:"+period);
System.out.println("年:"+period.getYears());
System.out.println("月:"+period.getMonths());
System.out.println("日:"+period.getDays());
System.out.println("出生到现在已经有:"+period.toTotalMonths()+"月");
}ChronoUnit:时间间隔(所有单位)
public static void main(String[] args) {
//计算时间间隔,年月日时分秒都可以计算
LocalDateTime birthday=LocalDateTime.of(2004,7,13,0,0,0);
LocalDateTime today=LocalDateTime.now();
System.out.println("相差的年数:"+ ChronoUnit.YEARS.between(birthday, today));
System.out.println("相差的月份:"+ChronoUnit.MONTHS.between(birthday,today));
System.out.println("相差的天数:"+ChronoUnit.DAYS.between(birthday,today));
System.out.println("相差的小 时:"+ChronoUnit.HOURS.between(birthday,today));
System.out.println("相差的分钟:"+ChronoUnit.MINUTES.between(birthday,today));
System.out.println("相差的秒数:"+ChronoUnit.SECONDS.between(birthday,today));
System.out.println("相差的周数:"+ChronoUnit.WEEKS.between(birthday,today));
}十五,包装类
**java中含有基本数据类型,但是某些特定情况下,需要把基本数据类型转换为引用(对象)类型,包装类就是对这些基本数据类型转换成对象(引用类型)之后的类。**
- 例如一些函数的参数是Objet,若直接传入基本数据类型会发生错误,若将基本数据类型对应的包装类传入参数(Object)中,就可以正常运行。
- 基本数据类型对应的包装类名称:
| 基本数据类型 | 包装类名 |
|---|---|
| byte | Byte |
| short | Short |
| char | Character |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| boolean | Boolean |
Jdk5之前,对于包装类的使用如下:
| 方法名 | 说明 |
|---|---|
| public Integer (int value) | 根据传递的整数创建一个lInteger对象 |
| public Integer (String s) | 根据传递的字符串创建一个integer对象 |
| public static Integer valueOf (int i) | 根据传递的整数创建一个lnteger对象 |
| public static Integer valueOf (string s) | 根据传递的字符串创建一个Integer对象 |
| public static Integer valueOf (String s, int radix) | 根据传递的字符串和进制创建一个Intege |
Jdk5之后,新增了对基本数据类型的自动装箱与拆箱功能,基本数据类型与包装类可自行进行转换。
注意:
在包装类Integer中,java在底层对于使用较多的-128~127的整数封装到了一个数组中,当创建包装类对象Integer时,会判断是否属于-128~127的范围,若属于该范围,则不会创建新的对象,不属于该范围,则创建新的Integer对象。
Integer i1=Integer.valueOf(127);
Integer i2=Integer.valueOf(127);
System.out.println("当i1,i2都为127时i1=i2:");
System.out.println(i1==i2);
System.out.println("修改i2的值为138后看i1的值会不会受影响:");
i2=138;
System.out.println(i1);
System.out.println(i2.intValue());
//修改之后,为i2创建了一个新的Integer类对象并把i2指向新的Integer对象的地址,i1仍为127
Integer i3=Integer.valueOf(128);
Integer i4=Integer.valueOf(128);
System.out.println("当i3,i4都为127时i3=i4:");
System.out.println(i3==i4);
//修改之前i1与i2指向的都是同一个(已经创建好的)Integer对象(127属于-128~127范围,系统有创建好的Integer对象,只需指向创建好的对象的地址即可,不需创建新对象)
//i3与i4指向的都是自己各自创建的新的Integer对象,他们的值128不在-128~127的范围之中,系统并未提前创建好对应的Integer对象Integer中常见的成员方法(多用于进制转换):
| 方法名 | 说明 |
|---|---|
| public static String toBinaryString (int i) | 得到二进制 |
| public static String toOctalString (int i) | 得到八进制 |
| public static String toHexString (int i) | 得到十六进制 |
| public static int parselnt (String s) | 将字符串类型的整数转成int类型的整数 |
进制转换返回值类型是String类型数据的原因:
- int类型数据不能以零开头,而进制大多都存在零开头的情况 在 Java 中,如果你把一个以 0 开头的数字字面量赋值给
int类型变量,编译器会将其解析为八进制数(基数为 8),而非十进制数。这可能导致与预期不符的结果。 若需使用固定进制的数据需要加特定的前缀表明: 使用前缀明确进制: 十进制:直接写数字(如 10)。 八进制:必须以 0 开头(如 010)。 十六进制:以 0x 开头(如 0xA 表示 10)。 二进制:以 0b 开头(如 0b1010 表示 10) - 进制类型的数字可能会超出int的范围,此时会出现返回值的错误
public static int parselnt (String s) 将字符串类型的整数转成int类型的整数
细节1:
在类型转换的时候,括号中的参数只能是数字不能是其他,否则代码会报错
细节2:
8种包装类当中,除了character都有对应的parseXxx的方法,进行类型转换
String str ="true"
boolean b = Boolean.parseBoolean(str);
System.out.println(b);正则表达式再复习一下!!!!!
多线程中的生产者消费者问题
生产者与消费者之间进行决定是否需要进行生产与消费时,通过中间变量**foodFlag**进行判断,foodFlag为1时表示当前仓库已满(为了方便理解所以仓库容量设为1)生产者需等待消费者进行消费拿走货物后在进行生产,为0时即为空,消费者需要等生产者生产完毕后再进行消费(取货)。
上述的等待通过
wait()进行等待,执行wait时,线程自动释放当前线程的锁,当被另外线程唤醒时,再进行重新获取为该线程指定的锁(也就是在上面synchronized处指定的锁),然后再进行上锁执行线程任务同样
notify()与notifyAll的作用是唤醒处于等待的进程notify()唤醒正在等待的单个进程,notifyAll()唤醒所有正在等待的进程,若采用了与锁绑定的形式即Desk.lock.notify()这样的形式的唤醒操作,便只会唤醒与Desk.lock锁绑定的执行了Desk.lock.wait()正在等待的线程
仓库**Desk**的代码定义如下:
//定义资源类,用来判断是否有空余资源
public class Desk {
//0为空1为满
public static Integer foodFlag = 0 ;
//消费者还需要的数量
public static Integer count = 10 ;
//创建锁对象
public static final Object lock = new Object();
}生产者**Cook**的代码如下:
//创建生产者类,继承Thread线程类
public class Cook extends Thread{
//重写其中的run方法,作为线程执行的任务
/*
消费者类,创建while死循环循环执行其中任务
首先把同步代码块用synchronized进行锁起来,当任务A获取到cpu执行权限时,就进行上锁,
此时就算cpu被其他线程抢走,也不会开始任务执行,只能先等任务A把任务执行完,然后进行解锁之后,其他任务才能在获取到cpu权限时
正常执行任务。
*/
@Override
public void run() {
while (true){
synchronized (Desk.lock) {
//首先判断消费者是否还需要资源,不需要则不生产
//若是仓库为满,也不生产
if(Desk.count == 0 ){
break;
}else{
//若是仓库为满,等待消费者取走商品在进行消费
if(Desk.foodFlag == 1){
try {
//把等待操作与锁绑定起来,这样唤醒进程的时候知道唤醒的是哪个进程
//Desk.lock.notify() 唤醒与当前锁绑定的线程
//若是直接进行notify则会唤醒所有进行等待的进程
Desk.lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}else{
//仓库为空进行生产商品,更新仓库的信息
Desk.foodFlag = 1;
System.out.println(Thread.currentThread().getName()+":生产者产出了一份商品,唤醒消费者进程");
//唤醒消费者进程进行取商品
Desk.lock.notifyAll();
}
}
}
}
}
}消费者**Customer**的代码如下:
//创建消费者类,继承Thread线程类,作为线程任务执行
public class Customer extends Thread{
//重写父类中的run方法,指定需要执行的任务类型
/*
消费者类,创建while死循环循环执行其中任务
首先把同步代码块用synchronized进行锁起来,当任务A获取到cpu执行权限时,就进行上锁,
此时就算cpu被其他线程抢走,也不会开始任务执行,只能先等任务A把任务执行完,然后进行解锁之后,其他任务才能在获取到cpu权限时
正常执行任务。
*/
@Override
public void run() {
while(true){
//对线程设置锁,锁的指定可以任意
synchronized (Desk.lock ){
//先进行判断现在消费者是否还需要资源,若没有,则跳出循环,让出执行权
if(Desk.count == 0){
break;
}else{
//仍需要资源,需继续进行判断,
//判断仓库上是否有资源,有资源即进行消费,没有则进行等待
//0没有,1有
if(Desk.foodFlag == 0){
//没有资源进行等待
try {
Desk.lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}else{
//有资源进行消费,然后使需要的资源量减一
Desk.count--;
//更新仓库的状态,
Desk.foodFlag = 0;
System.out.println(Thread.currentThread().getName()+":消费者在进行消费,还需要"+Desk.count+"件商品");
//唤醒生产者进程让他继续生产资源
Desk.lock.notifyAll();
}
}
}
}
}
}开启多线程进行任务执行:
//主任务,进行执行线程,开启多线程任务
public class CookCustomer {
public static void main(String[] args) {
//创建线程对象
Cook cook = new Cook();
Customer customer =new Customer();
//给线程起名字,便于再控制台查看线程信息
cook.setName("生产者进程");
customer.setName("消费者进程");
//开始执行线程任务
cook.start();
customer.start();
}
}唤醒的进程的执行过程
1. 从哪里开始执行?
当 Cook 进程被唤醒并重新抢占到 CPU 执行权(且获取到锁)后,它确实是从 Desk.lock.wait() 的下一行代码开始执行的。
2. 执行流程分析
正如你观察到的,代码逻辑如下:
- 唤醒后:执行完
try-catch块。 - 结束当前判断:由于
wait()是在if(Desk.foodFlag == 1)分支里的最后一段逻辑,唤醒后该if分支就结束了。 - 退出同步块:接着会执行到 第 51 行 结束
synchronized同步代码块,从而释放锁。 - 重新循环:由于外层是一个
while(true)死循环,它会立即跳转到 第 16 行 开始下一次循环。 - 完成生产:在新的循环中,它会再次判断
if(Desk.count == 0),然后进入else。此时因为消费者已经把菜取走了,Desk.foodFlag变成了0,所以它会进入 else 分支(第 37-48 行) 完成生产并打印。
总结
它被唤醒后并不会直接跳到同级的 else 去生产,而是走完当前循环的剩余部分,通过下一次循环重新判断条件,最终进入生产逻辑。
这种“唤醒后重新判断”的机制在多线程编程中是非常安全的做法(通常建议将 wait 放在 while 循环中判断条件,就是为了防止“虚假唤醒”,这里的 while(true) 配合内部 if 判断起到了类似的效果)。
wait()代码执行后会获取一个新的锁吗?
不是获取一个“新”的锁,而是重新抢夺并获取之前释放的那个锁。
为了更准确地理解,我们可以把 wait() 的执行过程拆解为三个阶段:
1. 释放锁(进入等待)
当你执行到 Desk.lock.wait() 时,当前线程会立即释放它持有的 Desk.lock 锁,并进入等待状态(Wait Set)。此时,其他线程(如消费者)才能抢到这个锁。
2. 被唤醒(等待锁)
当另一个线程调用 Desk.lock.notifyAll() 时,正在等待的 Cook 线程被唤醒。但被唤醒并不代表能立即执行下一行代码。
3. 重新获取锁(恢复执行)
这是最关键的一步:被唤醒的线程必须重新参与锁的竞争。
- 如果锁现在被其他线程占用,它就在同步块外面等着。
- 一旦它重新抢到了
Desk.lock锁,它才会从wait()方法中返回,接着执行后面的代码。
总结
- 不是新锁:它获取的依然是 synchronized (Desk.lock) 括号里指定的那个锁对象。
- 自动获取:是的,这个“重新抢锁”的过程是由 JVM 自动完成的。如果抢不到锁,代码会卡在
wait()这一行不往后走,直到抢到锁为止。
所以,当 wait() 之后的代码开始执行时,它一定已经重新持有了那个同步锁。
动态代理
当项目中一些对象已经设置好,这时想要添加新的方法,如果直接去修改对象的源码去添加方法的话,由于项目中该对象已经关联了许多业务,直接修改可能会造成无法预期的错误,这时为了在拓展功能的时候不发生错误,便采用动态代理的技术来满足添加方法的需求。
举个明星与经纪人的例子,当我们作为活动举办方想邀请某个明星来进行唱歌,此时我们可以通过联系他的经纪人来达成我们的目的,这个明星就是已经写好的对象,他的经纪人即为动态代理对象。
我们想要邀请明星来唱歌,肯定要布置好唱歌的场地,话筒,门票等准备工作,准备工作做好后,明星只负责过来唱歌。这里的一系列准备工作就是动态代理对象(经纪人)对旧对象(明星)的功能扩充,既实现了对新功能的扩充,也避免了产生未知错误的现象。
同时经纪人(动态代理对象)那里也必须具有与明星(旧对象)相同的唱歌行为(方法),若是作为活动举办方的我们在经纪人哪里看不到我们想要的需求(也就是唱歌方法),我们便不知道这个明星(旧对象)是否能满足我们的需求。
综上所述,动态代理对象的内容,一是需要包含我们想要拓展的新功能,二是需要包含旧对象的原有的我们需要使用的功能(也就是我们在包含拓展功能的同时,另外只需要包含我们需要使用到的功能即可),这个功能是通过接口来规范指定的, 定义一个接口,接口中包含需要使用的方法,同时让旧对象与动态代理对象均实现该接口,并重写该接口中的方法,即可实现动态代理对象中准确包含我们需要使用的,且存在于旧对象中的方法,同时不会出现多余的方法。
动态代理的具体实现原理
首先定义一个接口(案例中命名为Star),该接口是用来定义需要进行代理的功能,然后旧对象(需要拓展功能的对象,命名为BigStar),与代理对象都需要实现该接口。
注意代理对象实现接口的方式与旧对象并不相同,旧对象直接用
Implements实现,但是代理对象是通过在Proxy类中的newProxyInstance方法的第二个参数来进行传递类数组new Class[]{Star.class}来实现接口的。
接口定义如下:
//定义代理需要代理的功能,也就是代理进行扩展功能时,需要使用到的旧对象中的方法
//动态代理对象的内容,一是需要包含我们想要拓展的新功能,二是需要包含旧对象的原有的我们需要使用的功能
//(也就是我们在包含拓展功能的同时,另外只需要包含我们需要使用到的功能即可),这个功能是通过接口来规范指定的,
// 定义一个接口,接口中包含需要使用的方法,同时让旧对象与动态代理对象均实现该接口,并重写该接口中的方法,
// 即可实现动态代理对象中准确包含我们需要使用的,且存在于旧对象中的方法,同时不会出现多余的方法。
public interface Star {
//定义需要使用的旧对像中的方法
public abstract String sing(String singName);
public abstract String dance(String danceName);
}旧对象(需要拓展功能的对象)实现接口方式如下:
//定义需要使用代理的对象
//动态代理对象的内容,一是需要包含我们想要拓展的新功能,二是需要包含旧对象的原有的我们需要使用的功能(也就是我们在包含拓展功能的同时,
// 另外只需要包含我们需要使用到的功能即可),这个功能是通过接口来规范指定的, 定义一个接口,接口中包含需要使用的方法,
// 同时让旧对象与动态代理对象均实现该接口,并重写该接口中的方法,即可实现动态代理对象中准确包含我们需要使用的,且存在于旧对象中的方法,同时不会出现多余的方法。
public class BigStar implements Star{
private String name;
public BigStar(){
}
public BigStar(String name) {
this.name = name;
}
@Override
public String sing(String singName) {
System.out.println("代理对象准备工作(拓展功能已经完成)开始执行旧对象中的sing方法。传递参数为:"+singName);
return "执行结束!!!";
}
@Override
public String dance(String danceName) {
System.out.println("代理对象准备工作(拓展功能已经完成)开始执行旧对象中的dance方法。传递参数为:"+danceName);
return "执行结束!!!";
}
}然后定义创建代理对象的工具类,该案例中为ProxyUtil,在该类中定义createProxy(参数传入需要被代理的对象)方法来进行创建动态代理对象,createProxy方法如下:
//创建动态代理的工具类
public class ProxyUtil {
//写出工具类中进行创建动态代理对象的方法
public static Star createProxy(BigStar bigStar){
//使用Proxy中的newProxyInstance来创建代理对象
Star star = (Star) Proxy.newProxyInstance(
ProxyUtil.class.getClassLoader(), //参数一:指定创建代理对象时的类加载器,也就是通过那个类来创建代理对象,就用哪个类的类加载器
//这是一个类数组(Class 数组)。
// 在 Java 中:
// new Class[]{...} 表示创建一个存放 Class 对象(字节码文件对象)的数组,因为代理可以代理多个功能,功能是通过接口来规范并指定的,所以可以传递多个接口的类对象
// Star.class 获取的是 Star 接口或类的字节码对象。
new Class[]{Star.class}, //参数二:指定创建代理对象时,代理对象都需要实现哪些旧对象中的方法,需要代理哪些方法,传入的是类数组
new InvocationHandler() { //参数三:通过方法指定通过代理对象拓展的新功能
/*
参数一:传入创建的代理对象
参数二:传入调用的代理方法
参数三:传入调用的代理方法需要的参数
*/
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//依据传入的方法的名字,来判断调用的是代理对象所代理的哪个方法
if("sing".equals(method.getName())){
//写入对旧对象中的sing方法扩展的功能
System.out.println("sing的前置准备工作完成");
} else if ("dance".equals(method.getName())) {
//写入对旧对象中的dance方法扩展的功能
System.out.println("dance的前置准备工作完成");
}
//调用对应的旧对象中的方法
return method.invoke(bigStar,args);
}
}
);
return star;
}
}方法底层使用java中定义好的Proxy中的newProxyInstance方法来进行创建动态代理对象,创建的是在createProxy方法中传入的对象的代理对象。
newproxyInstance方法有三个参数:
- 参数一: 指定使用哪个类加载器来把Proxy中的newInstance创建的代理对象加载到字节码文件中,一般是在哪个类中调用的
Proxy.newInstance方法就用哪个类加载器,例如该案例中使用ProxyUtil类中定义的createProxy方法来实现创建代理对象,createProxy方法中通过调用Proxy.newInstance来创建对应的代理对象,所以第一个参数传入的就为ProxyUtil.class.getClassLoader - 参数二:传入的为类数组,其中包含代理对象需要实现的哪些接口为什么,也就是指定代理对象需要代理的功能有哪些,传入的是接口的字节码文件
- 参数三:传入一个
InvocationHandler的对象,作用是指定代理对象对方法增强的功能为什么,他有三个参数:- 参数一:传入创建好的代理对象。
- 参数二:传入要执行的方法的方法名。
- 参数三:传入代理对象要对方法拓展(添加)的功能
其实参数三就是一个
lamda表达式,与js中的箭头函数类似,当作参数传入方法之后,实际上交给方法,方法中会指定该表达式(或者箭头函数)在哪个位置执行,例如此处就是,在调用Proxy.newproxyInstance之后,创建好代理对象proxy后便会调用该lamda表达式(箭头函数)执行相应功能
使用代理对象:
//使用代理对象来满足需求,对原有对象的功能进行拓展,并避免引起未知错误
public class ProxyExample {
public static void main(String[] args) {
//创建明星(需要被代理的对象)
BigStar bigStar = new BigStar();
//创建动态代理对象
Star proxy = ProxyUtil.createProxy(bigStar);
//通过代理对象调用需要使用的方法
proxy.sing("这是sing方法");
//通过代理对象调用需要使用的方法
proxy.sing("这是dance方法");
}
}其中调用proxy.sing("这是sing方法")时,其实就是把代理对象proxy,方法名sing,方法参数("这是sing方法")分别作为参数传入newproxyInstance方法的第三个参数,方法参数(该参数为一个方法参数有三个参数)的三个参数中。
使用反射获取属性文件中的信息
首先我们在属性文件中进行配置文件信息如下:
classname = com.itheima.myreflect6.Student
method = study然后使用反射来获取属性文件中的信息(其实就是获取到classname,和method的值),然后通过反射可以获取到各种方法,属性,以及创建对象
反射是先来获取到对象的字节码文件的对象,然后通过字节码文件的对象,来获取该类的各种信息
具体案例如下:
//1.读取配置文件中的信息
//先创建配置文件的对象,然后调用load()方法来把配置文件中的信息封装到配置文件对象中
Properties prop = new Properties();
//load()方法需要一个流对象所以这里创建一个输入流对象,把配置文件信息输入到配置文件对象中
FileInputStream fis = new FileInputStream("myreflectllprop.properties");
prop.load(fis);
fis.close();
System.out.println(prop);
//2.获取全类名和方法名
//prop对象是一个双列集合,通过其中的get方法来获取值
String className = (String) prop.get("classname");
String methodName = (String) prop.get("method");
System.out.println(className);
System.out.println(methodName) ;
//3.利用反射创建对象并运行方法
//使用反射获取各种信息时,需要先获取对象的字节码文件对象
Class clazz = Class.forName(className);
//获取构造方法
Constructor con = clazz.getDeclaredconstructor();
//利用获取到的构造方法调用newInstance()来创建对象
Object o = con.newInstance();
System.out.println(o);
//获取成员方法并运行
Method method = clazz.getDeclaredMethod(methodName);
//取消签名认证,也就是强行使用私有属性的方法或者属性
method.setAccessible(true) ;
//调用创建的对象中的获取到的方法
method.invoke(o);枚举
@Getter
public enum UserStatus {
Normal(1,"正常"), //定义的枚举
Freeze(2,"冻结");
private int value;
private String desc;
UserStatus(int value, String desc) {
this.value = value;
this.desc = desc;
}
}枚举类型不只是定义一组固定的变量,然后取值只能从固定的这几个变量中取,枚举是一个类,类中可以定义成员方法,构造方法,成员变量。
核心
之前理解的**“枚举 = 一组固定常量”**只对了一半。
带成员变量 + 构造器的枚举 = 给每个常量“附加更多信息”
1. MALE、FEMALE 到底是什么?
MALE("男", 1),
FEMALE("女", 0);答案:
MALE 和 FEMALE 是 Gender 这个枚举类型的“实例对象”!
就像:
new Student("张三", 18)是学生对象MALE就是一个 Gender 类型的对象
2. 为什么要写成员变量 + 构造器?
作用:给枚举对象“贴属性标签”
如果不写成员变量和构造器,枚举只能是光秃秃的名字:
public enum Gender {
MALE, FEMALE;
}你只能拿到:MALE、FEMALE
但业务里我们需要:
- MALE 对应中文:男
- MALE 对应编码:1
- FEMALE 对应中文:女
- FEMALE 对应编码:0
成员变量就是用来存这些“附加信息”的
private final String desc; // 存中文
private final Integer code; // 存编码构造器就是给这些属性赋值的
Gender(String desc, Integer code) {
this.desc = desc;
this.code = code;
}3. 为什么写成 MALE("男",1) 这种形式?
这不是 key-value!
这是:创建对象时调用构造方法!
等价于:
Gender MALE = new Gender("男", 1);
Gender FEMALE = new Gender("女", 0);枚举语法只是把它简化成了:
MALE("男", 1),
FEMALE("女", 0);4. 整体关系(
public enum Gender {
// 对象名(传给构造器的参数)
MALE("男", 1), // 第一个对象
FEMALE("女", 0); // 第二个对象
// 对象的属性
private final String desc;
private final Integer code;
// 给属性赋值的构造器
Gender(String desc, Integer code) {
this.desc = desc;
this.code = code;
}
}对应关系:
MALE→ 对象名"男"→ 赋值给desc1→ 赋值给code
5. 实际开发价值
你可以直接从枚举对象里拿中文和编码:
// 获取男
Gender gender = Gender.MALE;
System.out.println(gender); // MALE
System.out.println(gender.getDesc()); // 男
System.out.println(gender.getCode()); // 1不用再写一堆 if/else:
// 不用写这种烂代码
if(gender == MALE) {
desc = "男";
code = 1;
}6. 总结
- MALE、FEMALE = 枚举对象
- 成员变量 = 对象的属性(中文、编码)
- 构造器 = 给对象属性赋值
- MALE("男",1) = 创建对象并赋值
- 目的 = 让每个常量不只是名字,还能携带业务数据
一句话
普通枚举 = 只有名字的常量
带构造器和成员变量的枚举 = 自带属性信息的常量对象
如果你愿意,我可以再给你画一张超清晰结构图,让你一眼看懂整个枚举的内部结构!
精炼、补全关键点:
MALE** /FEMALE
就是Gender类里提前写死、唯一、public static final 的全局实例对象**,外部不能new Gender(...)手动造对象。- 枚举天生限制:
整个程序里,Gender类型只能有这两个固定对象,取值逃不出MALE、FEMALE,从根源保证取值安全,不会乱传无效值。 MALE("男",1)这一句:
等价简化版的:
public static final Gender MALE = new Gender("男", 1);用私有构造给这个对象的 desc / code 两个属性赋上业务数据。
- 对比普通常量
以前写:
public static final int MALE = 1;
public static final int FEMALE = 0;容易传错数字、无校验;
枚举直接锁死只能是两个合法对象,类型安全 + 自带描述码值,更规范。
总结:
全局只能用这两个,杜绝非法值;
括号里参数给对象绑定中文描述、数字编码,方便业务取用。**