第五堂java基础课:从递归调用栈到排序算法,深入理解了JVM底层执行机制
今天内容密度比前几节课都大——从JVM栈帧执行机制,到递归调用栈的完整过程,再到静态与非静态的内存分配差异,最后落到冒泡排序和选择排序的代码实现。
信息量不小,我按上课顺序,把每个环节都细拆一下。
一、栈帧执行与值传递的底层原理
第一节课接着上回JVM内存模型往下讲,这次深入到了栈帧(Stack Frame)的层面。
栈帧里有什么?
每次调用一个方法,JVM就会在栈里为这个方法分配一个栈帧。一个栈帧包含三块核心内容:
- 局部变量表:存放方法的参数和方法内部定义的局部变量。这里的变量分两种——基本类型直接存值,引用类型存堆中对象的地址。
- 操作数栈:用于存放计算过程中的中间结果,比如执行
int c = a + b时,先把a和b压入操作数栈,然后弹出做加法,结果再压回去。 - 方法返回地址:方法执行完毕后应该回到调用者的哪个位置继续执行。
方法调用就是入栈(压栈),方法结束就是出栈(弹栈)。栈顶的方法永远是当前正在执行的那个。
值传递那个经典例子
老师用一段代码演示了"交换两个对象"的失败案例,我贴在下面方便回顾:
publicclassTest{publicstaticvoidswap(Personp1,Personp2){Persontemp=p1;p1=p2;p2=temp;}publicstaticvoidmain(String[]args){Persona=newPerson("张三");Personb=newPerson("李四");swap(a,b);// 猜猜a和b现在指向谁?}}结果很多人猜错了。实际上a还是指向张三,b还是指向李四,交换失败。
原因是:main方法里的a和b存的是两个对象的地址(假设是0x001和0x002)。调用swap时,JVM把a和b的值复制了一份交给p1和p2,现在p1=0x001,p2=0x002。swap方法里交换的是p1和p2这两个形参副本的指向——它们变成了p1=0x002,p2=0x001。但main里的a和b始终没变过,还是0x001和0x002。
那为什么传数组进去改元素就能改成功?
因为数组也是引用类型。传数组时传递的是地址副本,p和arr指向堆里同一个数组对象。通过p[2] = 100修改的是这块堆内存里的内容,当然会影响到arr。这跟在方法里让p重新指向一个新数组是两码事——后者改的是形参指向的目标,前者改的是目标内部的数据。
二、递归调用栈的执行流程(以斐波那契f(4)为例)
这部分是今天感触最深的。以前学递归只知道"自己调用自己",但完全不知道底层是怎么执行的。老师用斐波那契数列的例子完整地走了一遍入栈出栈的路径。
斐波那契的代码
publicintfib(intn){if(n==1||n==2)return1;returnfib(n-1)+fib(n-2);}调用fib(4),结果应该是3。但这个结果是"怎么"算出来的?老师一步步画了栈的变化。
完整的调用树
fib(4)的执行顺序可以画成一棵树:
fib(4) / \ fib(3) fib(2) / \ | fib(2) fib(1) 1 | | 1 1执行顺序(关键!)
很多初学者会误以为先算左边一整棵子树,再算右边。实际上递归的执行顺序是这样的:
1. 调用fib(4) → 入栈 2. 调用fib(3) → 入栈 3. 调用fib(2) → 入栈 → 返回1 → 出栈 4. 调用fib(1) → 入栈 → 返回1 → 出栈 fib(3) = 1+1=2 → fib(3)出栈 5. 调用fib(2) → 入栈 → 返回1 → 出栈 fib(4) = 2+1=3 → fib(4)出栈栈帧的变化过程:
当执行到最深处时,栈里同时存在fib(4)、fib(3)、fib(2)三个栈帧,从栈底到栈顶依次排列。栈顶的fib(2)最先执行完并返回,然后fib(3)拿到结果继续执行,再调fib(1),以此类推。
老师在黑板上画了完整的压栈弹栈路径图,每一层的返回值怎么向上传递看得清清楚楚。递推是压栈,回归是弹栈——这句话我记在笔记本上了,以后再分析递归逻辑就以这个为基础来思考。
三、static与非static:类加载就绪 vs 对象创建才有
下午第二节课,老师从类加载的视角重新讲了一遍静态和非静态的区别,视角很底层。
类加载的过程
写了一个类,javac编译成.class文件,JVM类加载器把.class加载进内存。加载之后会做两件事:
在方法区的类常量池里存放这个类的元信息——类名、属性定义、方法定义等。这些只是"图纸",不是实际数据。
如果类里有static修饰的成员,JVM会在静态常量池(也在方法区里)为它们分配内存空间。静态变量直接初始化,静态方法准备好被调用。
关键时间点差异:
- 静态成员:类加载完成后就存在了,可以通过类名直接访问。不需要new对象。
- 非静态成员:类加载时只是在类常量池里存了一份"模板"(比如"这个类有一个叫name的String属性"),但还没有真正的数据空间。必须new出对象后,在堆里创建实例,非静态属性才有了具体的存储位置。
一个形象的类比:
类是一栋楼的建筑图纸。静态成员是图纸上就标好的公共设施——比如楼顶的消防水箱、一楼的总电表箱,图纸完成(类加载)后这些设施就相当于存在了,可以直接使用。
而非静态成员是每家每户的内部装修——图纸上会标明"每户有客厅和卧室",但不盖楼(new对象),这些空间根本就不存在。每盖一户(new一个对象),就有一套独立的客厅和卧室。
用这个思路去理解静态方法为什么不能直接访问非静态成员就很容易了——类加载时静态方法已经可以用了,但非静态成员对应的对象空间可能根本还没创建,能访问到才怪。
四、排序算法:冒泡与选择
课程最后讲了两个最基础的排序算法。虽然时间复杂度都是O(n²),但作为入门算法,它们把"比较+交换"的基本模式讲得很清楚。
冒泡排序:相邻比较,大的往后浮
核心思路:从左到右依次比较相邻的两个元素,如果左边的比右边大,就交换。这样一轮下来,最大的元素就像气泡一样"浮"到了最右边。然后缩小范围,继续重复。
代码实现:
publicvoidbubbleSort(int[]arr){intn=arr.length;for(inti=0;i<n-1;i++){booleanswapped=false;// 优化标志for(intj=0;j<n-1-i;j++){if(arr[j]>arr[j+1]){inttemp=arr[j];arr[j]=arr[j+1];arr[j+1]=temp;swapped=true;}}if(!swapped)break;// 没交换说明已经有序}}外层循环i控制轮数,每轮确定一个最大元素放到末尾。内层循环j控制比较位置,范围逐渐缩小。swapped标志位是优化:如果某一轮一个交换都没发生,说明数组已经排好序了,可以提前结束。
选择排序:每轮找最小,放到最前
核心思路:第一轮从整个数组里找出最小的元素,放到索引0的位置;第二轮从索引1到末尾找出最小的,放到索引1的位置;以此类推。
代码实现:
publicvoidselectionSort(int[]arr){intn=arr.length;for(inti=0;i<n-1;i++){intminIdx=i;for(intj=i+1;j<n;j++){if(arr[j]<arr[minIdx]){minIdx=j;}}if(minIdx!=i){inttemp=arr[i];arr[i]=arr[minIdx];arr[minIdx]=temp;}}}外层循环i表示已排序区间的末尾边界。内层循环j遍历未排序区间,找到最小值索引,最后和i位置交换。
两种排序的区别在于:冒泡每轮可能交换多次,选择每轮最多交换一次。但总体来说都是O(n²)级别的,数据量一大效率下降明显。老师说掌握这两种是为了理解"比较排序"的基本思路,后面学更高效的排序算法时会用到相同的底层逻辑。
五、课后任务与感受
作业量确实不小:
- 画15张内存图,涵盖基本类型、引用类型、数组、对象嵌套等场景,课前提交截图。
- 独立编写冒泡排序和选择排序的代码并运行,发截图到群里。
- 老师建议今天4小时的课,周末要花6-8小时复盘消化。这个周末基本交给数据结构了。
说几点真实感受:
- 递归调用栈的演示让我对"递归是怎么在底层执行的"有了全新的认识。以前只是会用,现在是能想象。
- 值传递的交换例子每次听都觉得懂了,但过段时间又容易模糊,还是得画图才能稳住。老师让我们周末画15张图可能就是这个原因。
- static和非static的时间点差异以前没认真想过,从类加载视角看完之后,一些之前模糊的概念(比如"静态方法为什么不能直接访问非静态成员")就自然通了。
老师说过一句话我觉得很有道理:“看到代码能想象出内存图,才算真正理解了。”
我现在大概到"看到简单代码能想象"的程度,复杂的还得动手画。先把15张图搞定再说吧。