java jvm内存地址_Java-JVM运行时内存区域

一、Java内存区域

f78655774778

内存区域.png

Java程序(.java文件)经过编译器编译之后,变成.class或者.jar等Java字节码,然后经过JVM加载.class文件之后,在执行引擎中把运行时数据区中的.class相关数据经过JIT或者解释执行成机器码。解释执行就是执行一行解释一行。JIT将热点代码直接编译成本地代码(机器码)

JVM只是一个翻译

运行时数据区:Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

虚拟机栈、本地方法栈、程序计数器是线程私有的数据区域,每个线程都独有一份。

二、运行时数据区域

栈的内存要远远小于堆内存,栈深度是有限制的,可能会发生StackOverFlowError问题,引发这样的问题,其实就是写一个递归无限调用就会出现。

而堆内存想要出问题,其实就是通过无限循环的方式创建对象即可。

先举一个例子:并且反编译成.class字节码

public class Person {

public static final int age = 11;

public static final String name;

static {

name = "111111";

}

public int work() {

int x = 1;

int y = 2;

int u = 222222220;

int z = (x+y)*10;

return z;

}

public static void main(String[] args) {

Person person = new Person();

person.work();

}

}

f78655774778

字节码代码.png

1.程序计数器

指向当前程序正在执行的字节码指令的地址。

在代码编译成.class文件字节码的时候,每个方法执行的时候,都会有一个Code:这个就是代表的每一行指令的行号,这个行号是针对当前执行方法的一个偏移量,这个行号其实就是程序计数器记录的字节码的地址。

有一个程序计数器,主要是因为线程切换调度的问题,因为多线程采用时间片片轮转机制,当一个时间片执行完成之后,有可能会进行线程切换,那么就会在执行一半的时候去执行另外一个线程,下一次切换回来这个线程继续执行的时候,就需要通过程序计数器回到上一次执行到的位置继续执行。时间片用完,有可能当前正在执行的线程会退出CPU的使用,被其他线程占有。

程序计数器是JVM运行时数据区中唯一不会OOM

处于CPU上,我们无法直接操作这块区域

字节码工作时就是通过改变程序计数器的值来选取下一条需要执行的字节码指令

Java虚拟机中的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器只会执行一条线程中的指令(而在多核处理器指的是一个内核),每个线程都有一个独立的程序计数器,各条线程之间计数器互不影响独立存储。因为过程轮转,是有时间规定的,如果一个方法在这个时间内没有执行完,就被挂起,下次回来继续执行,而要回来继续就需要通过程序计数器,因为程序计数器会记录当前的方法执行到哪一步

2.虚拟机栈

不同的系统,会有不同的默认大小,一般64位的系统默认大小是1M,32位的默认是320KB。这块栈空间,是线程私有的,每个线程都拥有。

局部变量表的内存空间是在编译器内就分配完成,方法在运行期间不会改变局部变量表的大小

存储当前线程运行方法所需的数据,指令,返回地址

线程私有,与线程生命周期共享

虚拟机栈是方法执行的内存模型,每一个java方法被执行的时候,这个区域会生成一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

栈帧中局部变量表,存放的局部变量有8种基本数据类型,以及引用类型(对象的内存地址)比如this就是类自身的引用,在操作数栈中计算出来得出的局部变量都会从操作数栈中出栈然后存在局部变量表中,这里存的并不等于是对象本身,可能是引用指针,也可能是一条字节码指令的地址

java方法的运行过程就是栈帧在虚拟机栈中入栈和出栈的过程,栈帧一定是方法运行到了才会压入,运行完成才会出栈。类中的每个方法对应一个栈帧。栈帧含有局部变量表,操作数栈,返回地址,动态连接。动态连接是解决多态的问题,即在运行时进行动态调整,在编译时不能确定,则需要动态连接

当线程请求的栈的深度超出了虚拟机栈允许的深度时,会抛出StackOverFlow的错误。栈溢出,方法递归调用比较容易出现该问题

当Java虚拟机动态扩展到无法申请足够内存时会抛出OutOfMemory的错误

(1)局部变量表

32位长度的地址,用于寻址。4G大小。第0个位置一般是this。一般是在编译期就对局部变量表完成内存的分配,方法运行期间不会改变局部变量表的大小

局部变量表存放了编译期可知的各种基本数据类型、对象引用(不等同于对象本身,可能是指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)

64位长度的long和double类型的数据会占用两个局部变量空间,其他的只占用1个。

局部变量表所需要的内存空间是在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,方法运行期间不会改变局部变量表的大小。

(2)操作数栈

每个方法执行,在内存中有多个步骤,那么每个步骤的执行其实是一次向操作数栈的入栈和出栈的过程。操作数栈中,如果使用到了局部变量表中的数据,那么就会将局部变量表中的索引压入操作数栈。如果是一个加法如:int a = 5 + 10;那么这个运算在编译期就会运算完成,然后a的索引会保存在局部变量表中。

在转成字节码之后,代码的执行是在虚拟机栈中执行,而在虚拟机栈中主要是依赖于操作数栈,操作数栈根据栈帧的入栈和出栈来达到代码执行的目的。每次入栈,都是执行一条字节码指令。

// Java代码:

public class Person {

public static final int age = 11;

public static final String name;

static {

name = "111111";

}

public int work() {

int x = 1;

int y = 2;

int z = (x+y)*10;

return z;

}

public static void main(String[] args) {

Person person = new Person();

person.work();

}

}

// Java代码Person类中的work方法对应的字节码指令

public work()I

L0

LINENUMBER 12 L0

ICONST_1 // 将int类型的1压入操作数栈中,位于栈顶

ISTORE 1 // 将栈顶int类型的数值存入局部变量表中的下标为1的位置

L1

LINENUMBER 13 L1

ICONST_2 // 将int类型的2压入操作数栈中,位于栈顶

ISTORE 2 // 将栈顶的int类型的数值2存入局部变量表中的下标为2的位置

L2

LINENUMBER 14 L2

ILOAD 1 // 从局部变量表中加载下标为1的int类型的数值1,入栈位于栈顶

ILOAD 2 // 从局部变量表中加载下标为2的int类型的数值2,入栈位于栈顶

IADD // 将栈顶两个int类型的数值出栈,再相加,然后将结果压入栈顶

BIPUSH 10 // 将单字节的常量值int类型的10压入栈中。就是将10拓展为int值

IMUL // 将栈顶的两个int类型的数值出栈,再相乘,将结果压入栈顶

ISTORE 3 // 将栈顶的int类型的数值存入局部变量表中下标为3的位置(这里是30)

L3

LINENUMBER 15 L3

ILOAD 3 // 从局部变量表中加载下标为3的int类型的数值(这里是30)

IRETURN // 返回int类型的数值

L4 // LOCALVARIABLE就是局部变量表的定义

LOCALVARIABLE this Lcom/nene/test/jvm/Person; L0 L4 0

LOCALVARIABLE x I L1 L4 1 //这里的最后一个1表示的是在局部变量表中的位置

LOCALVARIABLE y I L2 L4 2 //

LOCALVARIABLE z I L3 L4 3 //

MAXSTACK = 2 // 最大的栈深度

MAXLOCALS = 4 // 最大局部变量表的深度,this,1,2,30

在使用操作数栈的时候,一般一个计算过程都是会在计算完成之后,将计算结果压入操作数栈,然后再从操作数栈中将结果存入局部变量表对应的下标位置。

栈的内存要远远小于堆内存,栈深度是有限制的,可能会发生StackOverFlowError问题,引发这样的问题,其实就是写一个递归无限调用就会触发。

而堆内存想要触发问题,其实就是通过无限循环的方式创建对象即可。

虚拟机栈的优化技术:

虚拟机有时候会对栈帧优化,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用的时候可以共用一部分数据,无需进行额外的参数的复制传递。

栈帧中,一般把动态链接和返回地址等其他的信息都称为栈帧信息。

(3)动态链接

动态链接库,还有多态,就是运行时需要去找到方法入口的,多态的话,需要在运行期间才能转化为直接引用,因为符号引用只有方法的具体地址和方法名,但是不知道方法的参数,而多态是方法名相同的,所以想要知道调用了多态的哪个方法,则需要在运行期间将符号引用转化为直接引用才可以知道,这部分就是动态链接。

其实动态链接除了链接方法的多态以外,还链接本地方法栈的native方法,用于直接调用native方法

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

动态链接:静态分派和动态分派。静态分派是根据对象创建的静态类型来分派,而动态分派是根据实际类型来分派。如果需要根据动态分派的话,那么在编译阶段并不能知道需要调用哪个实际类型的方法,只有在执行阶段才可以知道,这个时候就是在动态链接中存了指向具体执行的方法的一个直接引用。

(4)返回地址

即一个方法最终执行的时候,最后一个步骤执行完成之后的索引,即调用程序计数器中的地址作为返回。异常就跟这个没关系

3.本地方法栈

这个区域,属于线程私有,顾名思义,区别于虚拟机栈,这里是用来处理Native方法(Java本地方法)的,而虚拟机栈是处理Java方法的。对于Native方法,Object中就有不少的Native的方法,hashCode,wait等,这些方法的执行很多时候都是借助于操作系统。

这一区域也有可能抛出StackOverFlowError 和 OutOfMemoryError

保存的是native方法的信息,当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单的通过动态链接并直接调用native方法。

4.方法区

方法区主要存放的是已被虚拟机加载的类信息、常量、静态变量、编译器编译后的代码等数据。

方法区属于线程共享区域

JDK1.7及以前可以称为永久代

垃圾回收很少光顾这个区域,不过也是需要回收的,主要针对常量池回收,类型卸载

常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性,

里面可以存放编译期生成的常量,所以可以通过cglib动态生成不断的编译代码,造成方法区的OOM

JDK1.8及以后,是叫元空间。

元空间可以使用机器内存,默认情况下不受限制,受限于机器内存。方便拓展。但是因为方法区是使用机器内存,那么使用的机器内存越多,那么就会挤压剩余空间,导致剩余空间越来越小,堆所能拓展的空间越来越小。比如元空间占用15G,而给堆通过-Xmx设置被分配的最大上限10G,可能机器内存只有20G,那么堆这里就只能是5G,达不到10G。

运行时常量池是在方法区中,在JDK1.8中,字符串部分被放入了堆中。即在堆中开辟了一块空间用于存放字符串数据,比如"11"

5.堆

存储绝大多数的对象,以及数组

无论是成员变量、局部变量、还是类变量,它们指向的对象都存储在堆内存中;

Java堆属于线程共享区域,所有的线程共享这一块内存区域

从内存回收角度,Java堆可被分为新生代和老年代,这样分能够更快的回收内存

从内存分配角度,Java堆可划分出线程私有的分配缓存区(Thread Local Allocation Buffer,TLAB),这样能够更快的分配内存

当Java虚拟机动态扩展到无法申请足够内存时会抛出OutOfMemory的错误

可达性分析:如果一些对象通过等号等方式与GC 的对象是可达的,那么这些就不会被回收。而如果与gc roots对象不是可达的,那么就是可以被回收。可达性算法就是判断对象的存活

Java堆和方法区为什么不用一份?而是使用了两个来区分?

堆:对象、数组,是频繁回收的,而方法区GC较少。

这其实就是动静分离的思想。静态的数据放在方法区,动态的放在堆。

本地线程分配缓冲(TLAB)

每个线程在java堆中预先分配一小块内存(一般在eden区),这块内存称为本地线程分配缓存,线程需要分配内存时,就在对应线程的TLAB(本地线程分配缓冲)上分配内存,当线程中的TLAB用完并且被分配到了新的TLAB时,这时候才需要同步锁定。通过-XX:+/-UserTLAB参数来设置虚拟机是否使用TLAB。就是实现划分好一块内存区域给对象,一般是一小块内存占百分之一,如果这块内存太小,则从新划一块更大的给对应的对象。默认情况下TLAB是开启的

6.直接内存-堆外内存

直接内存其实就是一块本地内存。不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。这块内存是不受java堆大小的限制,但是会受本机总内存的限制,可以通过MaxDirectMemorySize来设置,默认大小与堆内存大小一致,所以直接内存也会出现OOM的问题。

f78655774778

直接内存.png

7.从底层深入理解运行时数据区

f78655774778

运行时数据区.png

public class JVMObject {

public final static String MAN_TYPE = "main";// 常量

public static String WOMAIN_TYPE = "woman";// 静态变量

public static void main(String[] args) throws Exception {

Teacher T1 = new Teacher();// 堆中 T1是局部变量

T1.setName("ZZQ");

T1.setSexType(MAN_TYPE);

T1.setAge(36);

for (int i = 0; i < 15; i++) {// 进行15次垃圾回收

System.gc();// 垃圾回收

}

Teacher T2 = new Teacher();

T2.setName("zzq1");

T2.setSexType(MAN_TYPE);

T2.setAge(18);

Thread.sleep(Integer.MAX_VALUE);// 线程休眠很久很久

}

}

堆内存会区分新生代和老年代。

(1)申请内存

(2)类加载-JVMObject.class和Teacher.class进入方法区。

(3)静态常量和静态变量也进入方法区

(4)运行方法,在栈空间中的虚拟机栈中,main()方法执行,有一个对应main()方法的栈帧入栈进入虚拟机栈

(5)new Teacher()是对象,放在堆中的Eden区,T1对象中的属性也是与T1对象一样跟随T1存放在堆中

(6)T1是引用,放在局部变量表中(其实就是虚拟机栈中)

(7)回收15次,T1对象进入老年代

(8)T2对象放入Eden区

(9)T2引用存放在方法main()栈帧中的局部变量表中

8.总结:深入辨析堆和栈

栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。

堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

9.内存溢出

(1)栈溢出

public class Stack{

public static void main(String[] args){

new Stack().test();

}

public void test(){

test();

}

}

栈也会出现OOM内存溢出,比如创建很多个线程,每个线程都会分配默认1M的内存空间给栈,那么当创建的线程的所有栈内存分配的总和超过了机器内存的总和,就会出现OOM

(2)堆溢出

public class Heap{

public static void main(String[] args){

ArrayList list=new ArrayList();

while(true){

list.add(new Heap());

}

}

}

(3)方法区溢出

通过cglib动态生成,不断的编译代码,生成即时编译后的代码。造成方法区内存空间不足

(4)本机直接内存(堆外内存)溢出

使用ByteBuffer.allocateDirect(12810241024);,然后限制堆外内存大小为100M,这样就会抛出OOM。会指向Direct buffer memory内存空间不足。

10.虚拟机优化技术

(1)编译优化技术

方法内联:

把目标方法原封不动移动到调用方法的方法体中,减少一次栈帧的入栈和出栈过程。

public class MethodDeal {

public static void main(String[] args) {

// max(1,2);//调用max方法: 虚拟机栈 --入栈(max 栈帧)

boolean i1 = 1>2;

}

public static boolean max(int a,int b){//方法的执行入栈帧。

return a>b;

}

}

(2)虚拟机栈优化技术

虚拟机有时候会对栈帧做优化,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用的时候可以共用一部分数据,无须进行额外的参数复制传递。

这样的情况一般是在两个方法中有数据传递,比如A方法中的参数在局部变量表中,B在操作数栈中,由A传到B的参数,这样A的局部变量表就可以与B的操作数栈有部分的重合。

比如:main()方法中调用work方法,work方法需要一个int类型的参数,main方法中调用work的时候,直接使用work(10);这个10是main方法的局部变量表中的变量,然后就会使用虚拟机栈优化技术。比如在work中也有使用了10,那么这个10的话,既在main方法的局部变量表中,也在work的操作数栈中,这样既会共用10这个基本类型对象。

public class JVMStack {

public int work(int x) throws Exception{

int z =(x+5)*10;//局部变量表有

Thread.sleep(Integer.MAX_VALUE);

return z;

}

public static void main(String[] args)throws Exception {

JVMStack jvmStack = new JVMStack();

jvmStack.work(10);//10 放入main栈帧操作数栈

}

}

(3)堆中的优化技术

通过使用本地线程分配缓冲(TLAB)进行堆的优化。

每个线程在java堆中预先分配一小块内存(一般在eden区),这块内存称为本地线程分配缓存,线程需要分配内存时,就在对应线程的TLAB(本地线程分配缓冲)上分配内存,当线程中的TLAB用完并且被分配到了新的TLAB时,这时候才需要同步锁定。通过-XX:+/-UserTLAB参数来设置虚拟机是否使用TLAB。就是实现划分好一块内存区域给对象,一般是一小块内存占百分之一,如果这块内存太小,则从新划一块更大的给对应的对象。默认情况下TLAB是开启的

三、使用实例分析运行时数据区

public class JVMTest {

public final static String MATH_TEACHER = "数学老师";

public static String CHINESE_TEACHER = "语文老师";

public static void main(String[] args) throws InterruptedException {

Teacher t1 = new Teacher();

t1.setName("zzq");

t1.setCourse(MATH_TEACHER);

t1.setAge(18);

for (int i=0;i<15;i++) {

System.gc();

}

Teacher t2 = new Teacher();

t2.setName("zzq111");

t2.setCourse(CHINESE_TEACHER);

t2.setAge(18);

Thread.sleep(Integer.MAX_VALUE);

}

}

class Teacher{

String name;

String course;

int age;

public String getName() {

return name;

}

public void setName(String name) {

this.name = name;

}

public String getCourse() {

return course;

}

public void setCourse(String course) {

this.course = course;

}

public int getAge() {

return age;

}

public void setAge(int age) {

this.age = age;

}

}

(1)申请JVMTest进程内存空间

(2)类加载—JVM.class和Teacher.class放入方法区

(3)静态常量和静态变量放入方法区

(4)运行方法,在栈空间中的虚拟机栈中,main方法()执行,有一个对应的main()的栈帧入栈进入虚拟机栈

(5)new Teacher()是对象,放在堆中的Eden区,T1对象中的属性也是与T1对象一样跟随T1存放在堆中

(6)T1是引用,放在局部变量表中

(7)回收15次,T1对象进入老年代

(8)T2对象放入Eden区

(9)T2引用方法main()栈帧的局部变量表中

栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。

堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。

四、内存溢出和栈溢出

1.栈空间

(1)栈溢出

其实就是栈帧的数量总和超过了栈空间的大小,超过了栈深度的限制。

实现栈溢出的代码,写一个递归:

public class Stack{

public static void main(String[] args){

new Stack().test();

}

public void test(){

test();

}

}

(2)OOM

栈也会出现OOM内存溢出,比如创建很多个线程,每个线程都会分配默认1M的内存空间给栈,那么当创建的线程的所有栈内存分配的总和超过了机器内存的总和,就会出现OOM。但是这样做,基本电脑就死机了。

2.堆空间

堆有OOM溢出问题,通过无限循环创建对象,向堆申请分配内存空间,每创建一个申请一块内存空间,堆内存空间不足以分配时OOM

public class Heap{

public static void main(String[] args){

// List list = new ArrayList();

// 做for循环,每次添加一个1M的对象

// list.add(new byte[1024*1024*1]);

ArrayList list=new ArrayList();

while(true){

list.add(new Heap());

}

}

}

3.方法区

通过cglib动态生成,不断的编译代码,生成即时编译后的代码。造成方法区内存空间不足

4.直接内存(堆外内存)OOM

使用ByteBuffer.allocateDirect(12810241024);,然后限制堆外内存大小为100M,这样就会抛出OOM。会指向Direct buffer memory内存空间不足。

五、虚拟机技术优化

1.编译优化技术

方法内联:

把目标方法原封不动移动到调用方法的方法体中,减少一次栈帧的入栈和出栈。比如一个方法

public int a(int x, int y) {

return x + y;

}

调用a方法,a(10,20);,如果不做方法内联的优化,那么在调用a方法时,就会还有一个栈帧入栈,而采用方法内联的优化之后,外部方法可以直接知道另一个方法的实现,所以直接将目标方法的实现放在调用方法的方法体中直接使用,减少一次栈帧的入栈和出栈。

2.虚拟机栈优化技术

虚拟机有时候会对栈帧做优化,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用的时候可以共用一部分数据,无须进行额外的参数复制传递。

这样的情况,一般是在两个方法中有数据传递,比如A方法中的参数在局部变量表,B在操作数栈,由A传到B的参数,这样A的局部变量表就可以与B的操作数栈有部分的重合。

比如:main方法中,调用work方法,work方法需要一个int类型的参数,main方法中调用work的时候,直接使用work(10);这个10是main方法的局部变量表中的变量,然后就会使用虚拟机栈的优化技术。

六、问题

方法区:类。类会在哪个时候卸载?回收。

(1)类——所有的实例都要回收掉

(2)加载该类的classloader已经被回收

(3)该类、java.lang.class对象没有任何地方被引用。无法通过反射访问该类的方法。

1.JVM有哪些内存区域(JVM的内存布局是什么)

程序计数器、虚拟机栈、本地方法栈、方法区、堆。另外还有直接内存,是堆外内存

2.StackOverFlow与OOM的区别?分别发生在什么时候,JVM栈中存储的是什么?堆中存的是什么?

堆中存的是对象;栈的话,本地方法栈存的是native方法的,而虚拟机栈的话,存储的是栈帧,存的是方法执行的数据、指令、地址,一个方法一个栈帧,而栈帧分为局部变量表、操作数栈、动态链接、返回地址,局部变量表中存的是8中基本数据类型变量和引用地址,操作数栈是存储方法执行指令地址或者引用指针,返回地址其实就是返回的程序计数器的记录的指令地址,动态链接存放的是Java多态在代码运行阶段调用对象的具体方法的一个直接引用,因为在编译期并不能知道需要执行什么方法

StackOverFlow其实就是栈帧的数量总和超过了栈空间的大小,超过了栈深度的限制。

OOM可以认为可用内存不足以分配给新的线程或者对象,即可用内存不足以支持JVM分配内存。比如栈空间的OOM,就是创建N多个线程,每个线程比如默认分配1M的空间给每个线程的虚拟机栈,当可用内存空间不足以分配时,就会引起OOM;堆空间的OOM,堆空间可用内存不足以分配给新创建的对象,就会引起OOM;方法区的OOM,借助于cglib,动态生成即时编译后的代码,当方法区空间不足以分配给新生成的即时编译后的代码时,引起OOM;直接内存也存在OOM。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值