java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java的内存管理机制

Java的内存管理机制详解

作者:纣王家子迎新

这篇文章主要介绍了Java的内存管理机制,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教

在Java中,内存管理机制是自动且相对复杂的,它主要由Java虚拟机(JVM)来负责。

这个机制确保了内存的有效分配和释放,从而帮助开发者避免了许多常见的内存管理问题,如内存泄漏和悬挂指针。

Java内存区域

Java的内存主要分为几个区域:

方法区(Method Area)

堆内存的结构

Java堆内存通常被划分为几个区域,如新生代(Young Generation)、老年代(Old Generation)以及永久代(在Java 8及以后版本中,永久代被元空间Metaspace取代)。这些区域各自承担着不同的角色和职责。

动态分配过程

堆内存溢出

堆结构的特点

完全二叉树:除了最后一层外,每一层都被完全填满,且所有节点都尽可能地向左对齐。

首先,是TreeNode类的定义: 

class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;

    TreeNode(int x) {
        val = x;
    }
}

接下来,是一个简单的递归函数来构建完全二叉树(这里为了简化,我们假设完全二叉树是通过层序遍历的序列来构建的,但通常完全二叉树不会这样直接构建,因为很多位置是空的,这里只是为了展示): 

// 注意:这个方法仅用于演示,实际上完全二叉树不会这样直接通过数组构建节点
// 因为完全二叉树中有很多空节点位置,这里我们假设所有位置都填满了有效值
TreeNode buildCompleteBinaryTreeFromLevelOrder(int[] nums) {
    if (nums == null || nums.length == 0) {
        return null;
    }
    
    Queue<TreeNode> queue = new LinkedList<>();
    TreeNode root = new TreeNode(nums[0]);
    queue.offer(root);
    
    int index = 1;
    while (index < nums.length) {
        TreeNode currentNode = queue.poll();
        
        if (index < nums.length) {
            currentNode.left = new TreeNode(nums[index++]);
            queue.offer(currentNode.left);
        }
        
        if (index < nums.length) {
            currentNode.right = new TreeNode(nums[index++]);
            queue.offer(currentNode.right);
        }
    }
    
    return root;
}

// 注意:上面的方法其实构建的是一个满二叉树,而非一般的完全二叉树
// 在完全二叉树中,很多位置可能是空的,这里为了简化没有处理空节点的情况

然而,正如注释中所说,上面的方法实际上构建的是一个满二叉树,而不是一个通用的完全二叉树。在完全二叉树中,许多节点可能是空的。由于直接通过数组构建这样的树在Java中并不直观(因为你需要处理空节点),我们通常会使用其他数据结构(如队列)来辅助构建,或者简单地通过递归调用构建特定形状的二叉树。

如果你只是想看看完全二叉树在数组中的表示,那么你可以理解为一个完全二叉树(非满)可以“填充”到一个数组中,其中数组的索引反映了树中的位置(根节点在索引0,左子节点在2i+1,右子节点在2i+2,其中i是父节点的索引),但数组中可能包含空值(或某种表示空节点的值)来表示树中的空位置。

堆属性:1.最大堆:每个父节点的值都大于或等于其任何子节点的值。2.最小堆:每个父节点的值都小于或等于其任何子节点的值。

堆结构的应用

优先队列:堆结构是实现优先队列(Priority Queue)的理想选择。优先队列是一种特殊的队列,其中每个元素都有一个优先级,元素的出队顺序基于它们的优先级,而不是它们被加入队列的顺序。最大堆和最小堆可以分别用来实现最大优先队列和最小优先队列。

使用 PriorityQueue 来存储整数,并根据整数的自然顺序(即从小到大)进行排序:

import java.util.PriorityQueue;

public class PriorityQueueExample {
    public static void main(String[] args) {
        // 创建一个默认的PriorityQueue,它将根据元素的自然顺序进行排序
        PriorityQueue<Integer> pq = new PriorityQueue<>();

        // 向优先队列中添加元素
        pq.add(3);
        pq.add(1);
        pq.add(4);
        pq.add(1);
        pq.add(5);

        // 遍历并移除(弹出)优先队列中的所有元素
        while (!pq.isEmpty()) {
            System.out.println(pq.poll()); // poll() 方法会移除并返回队列头部的元素
        }

        // 输出结果将按从小到大的顺序显示:1, 1, 3, 4, 5
    }
}

在这个例子中,我们创建了一个 PriorityQueue<Integer> 类型的队列,并向其中添加了几个整数。由于我们没有为 PriorityQueue 提供 Comparator,因此它将使用整数的自然顺序进行排序。然后,我们使用一个 while 循环和 poll() 方法来遍历并移除队列中的所有元素。poll() 方法会移除并返回队列头部的元素(即优先级最高的元素),在这个例子中,就是数值最小的元素。

堆排序:堆排序是一种基于比较的排序算法,它利用堆结构进行排序。首先,将待排序的序列构造成一个最大堆(或最小堆),此时,整个序列的最大值(或最小值)就是堆顶的根节点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

使用了最大堆的性质来进行排序,使得数组的第一个元素是最大值,然后将其与数组的最后一个元素交换,之后减小堆的大小(排除已排序的最大元素),再次将剩余的元素调整为最大堆,并重复上述过程,直到整个数组排序完成。

public class HeapSort {
    
    // 用于构建最大堆的辅助函数
    private static void buildMaxHeap(int arr[], int n) {
        for (int i = n / 2 - 1; i >= 0; i--)
            heapify(arr, n, i);
    }

    // 调整给定索引处的元素,使其符合最大堆的性质
    private static void heapify(int arr[], int n, int i) {
        int largest = i; // 初始化最大为根
        int l = 2 * i + 1; // 左子节点
        int r = 2 * i + 2; // 右子节点

        // 如果左子节点大于根节点
        if (l < n && arr[l] > arr[largest])
            largest = l;

        // 如果右子节点大于当前的最大值
        if (r < n && arr[r] > arr[largest])
            largest = r;

        // 如果最大值不是根节点,则交换
        if (largest != i) {
            int swap = arr[i];
            arr[i] = arr[largest];
            arr[largest] = swap;

            // 递归地调整受影响的子树
            heapify(arr, n, largest);
        }
    }

    // 主要的堆排序函数
    public static void sort(int arr[]) {
        int n = arr.length;

        // 构建最大堆
        buildMaxHeap(arr, n);

        // 一个个从堆顶取出元素
        for (int i = n - 1; i > 0; i--) {
            // 移动当前根到末尾
            int temp = arr[0];
            arr[0] = arr[i];
            arr[i] = temp;

            // 调用max heapify在减少的堆上
            heapify(arr, i, 0);
        }
    }

    // 驱动代码
    public static void main(String args[]) {
        int arr[] = {12, 11, 13, 5, 6, 7};
        int n = arr.length;

        HeapSort ob = new HeapSort();
        ob.sort(arr);

        System.out.println("Sorted array is");
        for (int i = 0; i < n; ++i)
            System.out.print(arr[i] + " ");
        System.out.println();
    }
}

栈结构的特点

栈结构的组成

Java中的栈实现

栈结构的应用

栈结构在编程中有广泛的应用,例如:

函数调用:在函数调用时,会将返回地址、参数等信息压入栈中,函数返回时再从栈中弹出这些信息。

代码示例:

public class FunctionCallExample {

    public static void main(String[] args) {
        int result = add(5, 3);
        System.out.println("The sum is: " + result);
    }

    public static int add(int a, int b) {
        return a + b;
    }
}

在这个例子中,main 方法调用了 add 方法,并传递了两个整数参数 5 和 3。当 add 方法被调用时,JVM会执行以下操作(在概念层面上):

需要注意的是,虽然这个过程涉及到了栈的使用,但Java程序员通常不需要直接管理栈的操作。JVM会自动处理这些底层细节

递归调用:递归调用过程中,每次调用都会将当前的状态压入栈中,直到找到基本情况,然后逐层返回,从栈中弹出状态。

代码示例:

这个示例将展示递归调用过程中如何将状态压入栈(尽管在Java中,这个栈是由JVM隐式管理的,我们通常不直接操作它),并逐层返回。我们将通过计算阶乘的递归函数来演示这一点。

public class RecursionExample {

    // 计算n的阶乘
    public static int factorial(int n) {
        // 基本情况:如果n是0或1,返回1
        if (n == 0 || n == 1) {
            return 1;
        }
        // 递归调用:将n-1的状态压入“隐式栈”中(实际上是由JVM的调用栈管理)
        // 然后计算n * (n-1)!
        else {
            return n * factorial(n - 1);
        }
    }

    public static void main(String[] args) {
        int number = 5;
        System.out.println("The factorial of " + number + " is " + factorial(number));
    }
}

在这个例子中,factorial 方法是一个递归函数,它计算并返回给定整数的阶乘。每次递归调用都会将当前的 n 值(或说,是当前的状态)压入到JVM的调用栈中。当 n 达到基本情况(即 n == 0 或 n == 1)时,递归开始逐层返回,并从栈中弹出之前的状态(虽然这个弹出过程是自动的,我们看不到),直到返回到最初的调用点。

注意,虽然我们说“将状态压入栈中”,但实际上在Java中,这是由JVM的调用栈自动管理的,我们不需要(也无法)直接操作它。这个示例仅仅是为了说明递归调用的工作机制。

表达式求值:在解析和计算数学表达式时,可以使用栈来保存中间结果和操作符。

代码示例

使用栈来解析和计算数学表达式(特别是中缀表达式)是一个经典的编程问题 该示例使用两个栈:一个用于保存数字(称为数字栈),另一个用于保存操作符(称为操作符栈)。

注意,为了简化,这个示例将只处理加法(+)和乘法(*),并且假设输入是一个有效的、格式良好的表达式,不包含括号。

import java.util.Stack;

public class ExpressionEvaluator {
    
    public static int evaluate(String expression) {
        Stack<Integer> numbers = new Stack<>();
        Stack<Character> operators = new Stack<>();

        int i = 0;
        while (i < expression.length()) {
            char ch = expression.charAt(i);

            if (Character.isDigit(ch)) {
                // 假设单个数字不会超过一位,为了简化处理
                int num = Character.getNumericValue(ch);
                numbers.push(num);
            } else if (ch == '+' || ch == '*') {
                // 遇到操作符时,可能需要处理之前的操作符和数字
                while (!operators.isEmpty() && hasPrecedence(operators.peek(), ch)) {
                    applyOp(numbers, operators.pop());
                }
                operators.push(ch);
            }
            i++;
        }

        // 处理所有剩余的操作符
        while (!operators.isEmpty()) {
            applyOp(numbers, operators.pop());
        }

        // 最终数字栈中应只剩下一个结果
        return numbers.pop();
    }

    private static boolean hasPrecedence(char op1, char op2) {
        // 乘法优先级高于加法
        if ((op1 == '*' && op2 == '+') || (op2 == '*')) {
            return true;
        }
        return false;
    }

    private static void applyOp(Stack<Integer> numbers, char op) {
        int val2 = numbers.pop();
        int val1 = numbers.pop();
        switch (op) {
            case '+':
                numbers.push(val1 + val2);
                break;
            case '*':
                numbers.push(val1 * val2);
                break;
        }
    }

    public static void main(String[] args) {
        String expression = "3+4*2";
        System.out.println("Result: " + evaluate(expression)); // 应输出 11
    }
}

这个代码示例展示了如何使用栈来解析和计算一个只包含加法和乘法的数学表达式。注意,这个实现为了简化而做了一些假设(比如数字都是单个的),并且没有处理括号。在实际应用中,你可能需要扩展这个实现以处理更复杂的表达式,包括多位数、括号和更多的运算符。

页面访问历史:在浏览器中,用户访问的页面历史可以用栈来保存,实现“前进”和“后退”功能。

程序计数器

本地方法栈

Java中堆内存和栈内存的主要区别

堆内存

栈内存

总结来说,堆内存和栈内存是Java内存管理中两个重要的部分,它们各自承担着不同的角色和用途。堆内存主要用于存储对象实例和数组,是线程共享的;而栈内存则主要用于存储局部变量和方法调用的上下文信息,是线程隔离的。

垃圾收集机制

1. 垃圾收集的对象

2. 垃圾收集算法

JVM中常用的垃圾收集算法包括以下几种:

3. 垃圾收集器

JVM提供了多种垃圾收集器,每种收集器都有其特点和适用场景。常见的垃圾收集器包括:

常见的垃圾收集器及其特点:

1. Serial 垃圾收集器

特点:Serial收集器是一个单线程的收集器,使用复制算法。它在进行垃圾收集时,会暂停其他所有的工作线程(即“Stop The World”)。尽管这会导致应用程序的短暂停顿,但在单核处理器或小型应用中,由于其简单性,它可能是一个高效的选择。

适用场景:主要适用于Client模式下的虚拟机,或单核服务器环境。

2. ParNew 垃圾收集器

特点:ParNew收集器是Serial收集器的多线程版本,使用多线程来执行垃圾收集,其余行为(包括控制参数、收集算法等)与Serial收集器相同。在多核CPU上,ParNew的回收效率通常高于Serial收集器。

适用场景:许多运行在Server模式下的虚拟机首选ParNew作为新生代收集器,特别是当需要与CMS收集器配合使用时。

3. Parallel Scavenge 垃圾收集器

特点:Parallel Scavenge收集器是一个注重吞吐量的新生代收集器,使用复制算法和并行多线程收集。它提供了两个参数用于精确控制吞吐量:-XX:MaxGCPauseMillis(控制最大垃圾收集停顿时间)和-XX:GCTimeRatio(设置吞吐量大小)。此外,它还支持自适应的GC调节策略。

适用场景:适用于后台运算而不需要太多交互的任务,追求高吞吐量和高效利用CPU资源。

4. Serial Old 垃圾收集器

特点:Serial Old是Serial收集器的老年代版本,同样使用单线程和“标记-整理”算法进行垃圾收集。它是运行在Client模式下的Java虚拟机默认的老年代垃圾收集器。

适用场景:主要用于Client模式或单核服务器环境,以及作为CMS收集器失败时的后备方案。

5. Parallel Old 垃圾收集器

特点:Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。它可以在老年代提供与新生代相同的吞吐量优先的垃圾收集。

适用场景:适用于对吞吐量要求较高的系统,特别是当新生代使用Parallel Scavenge收集器时,可以与之搭配使用以保证整体的吞吐量。

6. CMS(Concurrent Mark Sweep)垃圾收集器

特点:CMS收集器是一种以获取最短回收停顿时间为目标的并发收集器,使用多线程和“标记-清除”算法。它的收集过程分为初始标记、并发标记、重新标记和并发清除四个阶段,其中并发标记和并发清除阶段可以与用户线程并发执行。

适用场景:适用于对停顿时间要求较高的应用场景,如Web服务器和B/S架构的应用。但需要注意的是,CMS对CPU资源敏感,且无法处理浮动垃圾,可能会出现“Concurrent Mode Failure”而导致Full GC。

7. G1(Garbage-First)垃圾收集器

特点:G1收集器在JDK 1.9之后成为默认的垃圾收集器。它兼顾响应时间和吞吐量,将堆内存划分为多个区域,每个区域都可以充当新生代、老年代或存放大对象。G1收集器采用标记复制算法进行垃圾收集,并支持并发标记和混合收集。

适用场景:适用于大多数应用场景,特别是需要同时兼顾响应时间和吞吐量的场合。

4. 触发条件

垃圾收集的触发条件通常包括:

5. 注意事项

Java垃圾收集器主要工作原理

内存碎片是什么?

内存碎片指的是在堆(Heap)内存中被分配和回收对象后,留下的不连续、无法被有效利用的内存空间。

这些碎片空间虽然存在,但由于其大小或位置的原因,无法被用来存储新的对象,从而导致内存的浪费。

内存碎片的分类

内存碎片主要分为两种:

内部碎片(Internal Fragmentation):

外部碎片(External Fragmentation):

垃圾收集与内存碎片

在垃圾收集过程中,如果JVM不采取适当的措施来管理内存碎片,那么随着时间的推移,堆内存中的碎片可能会越来越多,导致可用的连续内存空间减少,从而影响新对象的分配效率。

为了解决这个问题,一些JVM实现采用了压缩(Compacting)技术。在压缩过程中,JVM会将所有的活动对象(即仍然被引用的对象)移动到堆的一端,从而消除外部碎片,并在堆的另一端形成一个连续的空闲内存区。这样,新的对象就可以被快速且连续地分配到这个空闲区域中,从而提高内存的使用效率和分配速度。

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文