Java学习篇【三、进制、数据类型与内存分析】

先来看一些声明例子:

int a, b, c;             // 声明三个int型整数:a、 b、c
int d = 3, e = 4, f = 5; // 声明三个整数并赋予初值
byte z = 22;             // 声明并初始化 z
String s = "bboyhan";    // 声明并初始化字符串 s
double pi = 3.14159;     // 声明了双精度浮点型变量 pi
char x = 'x';            // 声明变量 x 的值是字符 'x'。

1、字节与进制

我们都知道,在计算机里边,所有的一切都是以二进制01的形式进行表示。在计算机中,表示数据的最小单位,叫位(bit),也叫比特位。byte,叫做字节。

1byte(也可以表示为1B)=8bit,1KB=1024B。字节换算表如下:

换算公式
1KB(Kilobyte,千字节)=1024B= 10^3 B
1MB(Megabyte,兆字节,百万字节,简称“兆”)=1024KB= 10^6 B
1GB(Gigabyte,吉字节,十亿字节,又称“千兆”)=1024MB= 10^9 B
1TB(Terabyte,万亿字节,太字节)=1024GB= 10^12 B
1PB(Petabyte,千万亿字节,拍字节)=1024TB= 10^15 B
1EB(Exabyte,百亿亿字节,艾字节)=1024PB= 10^18 B
1ZB(Zettabyte,十万亿亿字节,泽字节)= 1024EB= 10^21 B
1YB(Yottabyte,一亿亿亿字节,尧字节)= 1024ZB= 10^24 B
1BB(Brontobyte,一千亿亿亿字节)= 1024YB= 10^27 B
1NB(NonaByte,一百万亿亿亿字节) = 1024BB = 10^30 B
1DB(DoggaByte,十亿亿亿亿字节) = 1024 NB = 10^33 B

由于数据在计算机中的表示,最终以二进制的形式存在,所以有时候使用二进制,可以更直观地解决问题。但你会发现二进制数太长了。比如int 类型占用4个字节,32位。

比如100,用int类型的二进制数表达将是:0000 0000 0000 0000 0110 0100

这样的表现形式从常人的理解来看,不易读懂也不直观。因此,C、C++、以及java中没有提供在代码直接写二进制数的方法。在Java中提供了八进制和十六进制的表现形式。

1、八进制表示

# 表示方法:在进制数前面加一个零(0)。

# 10进制的100(即 1*10^2+0*10^1+0*10^0 = 100)
int a = 100;

# 8进制的100(即 1*8^2+4*8^1+4*8^0 = 100)
int a = 0144;

2、十六进制表示

# 表示方法:在进制数前面加一个0x。

# 10进制的100(即 1*10^2+0*10^1+0*10^0 = 100)
int a = 100;

# 16进制的100(即 6*16^1+4*16^0 = 100)
int a = 0x64;

2、数据类型

我们在上一篇中了解了“变量”的概念,现在就来深入理解一下。变量就是申请内存来存储值。也就是说,当创建变量的时候,需要在内存中申请空间。内存管理系统根据变量的类型为变量分配存储空间,分配的空间只能用来储存该类型数据。

因此,通过定义不同类型的变量,可以在内存中储存整数、小数或者字符。Java 分为两大数据类型:内置数据类型(也叫基本类型)、引用数据类型

  • 基本类型: 简单数据类型是不能简化的、内置的数据类型、由编程语言本身定义,它表示了真实的数字、字符和整数。

  • 引用数据类型: Java语言本身不支持C++中的结构(struct)或联合(union)数据类型,它的复合数据类型一般都是通过类或接口进行构造,类提供了捆绑数据和方法的方式,同时可以针对程序外部进行信息隐藏。

2.1 内置数据类型

Java语言提供了八种基本类型。六种数字类型(四个整数型,两个浮点型),一种字符类型,还有一种布尔型。

类别 类型 说明 字节数 取值返回 默认值
整数型 byte Java中最小的数据类型,在内存中占8位(bit) 1 -128~127 0
short 短整型,在内存中占16位 2 -32768~32717 0
int 整型,用于存储整数,在内在中占32位 4 -2147483648~2147483647 0
long 长整型,在内存中占64位 8 -263~263-1 0L
浮点型 float 单精度浮点型,在内存中占32位,即4个字节,用于存储带小数点的数字(与double的区别在于float类型有效小数点只有6~7位) 4 3.4e-45~1.4e38 0.0f
double 双精度浮点型,用于存储带有小数点的数字,在内存中占64位 8 4.9e-324~1.8e308 0.0d
字符型 char 字符型,用于存储单个字符,占16位,Unicode码,用单引号赋值 2 0~65535 `\u0000`
布尔型 boolean 布尔类型,占1个字节,用于判断真或假 1 仅有两个值,即true、false false

注: 实际上,JAVA中还存在另外一种基本类型void,它也有对应的包装类 java.lang.Void,不过我们无法直接对它们进行操作。

2.2 引用数据类型

在Java中,引用类型的变量非常类似于C/C++的指针。引用类型指向一个对象,指向对象的变量是引用变量。对象、数组都是引用数据类型。 所有引用类型的默认值都是null。

User a = new User(“bboyhan”),即a为引用变量,new User("bboyhan")为创建的一个对象实例,a变量指向这个对象。

2.3 数据类型与内存的关系

Java的数据类型定义之后进行内存分配,有1个前提条件,即确定变量的类型。确定了变量的类型,即确定了数据需分配内存空间的大小,数据在内存的存储方式。

  • 基本数据类型:所有的内置数据类型不存在“引用”的概念,基本数据类型都是直接存储在内存中的内存栈上的,数据本身的值就是存储在栈空间里面

  • 引用数据类型:引用类型继承于Object类(也是引用类型)都是按照Java里面存储对象的内存模型来进行数据存储的,使用Java内存堆和内存栈来进行这种类型的数据存储。简单地讲,“引用”是存储在有序的内存栈上的,而对象本身的值存储在内存堆上的。由此可见,不管何种引用类型的变量,他们引用的都是对象。

3、内存分析(堆、栈、方法区)

Java的程序运行,得益于JVM(Java虚拟机)。因此,在谈到Java的内存问题,其实指的是JVM的内存问题。首先,我们先来理解一下Java程序的执行过程:

java程序执行过程

首先,Java源代码文件(.java后缀)会被Java编译器编译为字节码文件(.class后缀),然后由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)。

运行时数据区 大致可分为:

  1. 堆(Heap)
  2. 栈(Stack),也叫Java虚拟机栈
  3. 方法区(Method Area)
  4. 程序计数器(Program Counter Register)
  5. 本地方法栈(Native Method Stack)

运行时数据区

3.1 堆(Heap)

在HotSpot JVM 实现中Heap内存被“分代”管理。分割为:

  1. Heap Memory 堆内存

堆内存是我们程序运行时可以申请的内存空间,用于存储程序运行时的数据信息。

  1. Non Heap Memory 非堆内存

除了堆内存区域用来存放存活(living)的数据,JVM 还需要尤其是类描述、元数据等更多信息。所以这些信息统一被存放在命名为Permanent generation(永久/常驻代)的区域。非堆内存其实就是JVM 留给自己用的,所以方法区、JVM 内部处理或优化所需的内存(如JIT编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法的代码等都在非堆内存中。并且,非堆内存由JVM 管理,我们无法在程序中使用。

Heap 是应用程序在运行期请求操作系统分配给自己的向高地址扩展的数据结构,是不连续的内存区域。由于从操作系统/JVM 管理的内存分配,所以在分配和销毁时都要占用时间,因此用堆的效率比较低。但是堆的优点在于:编译器不必知道要从堆里分配多少存储空间,也不必知道存储的数据要在堆里停留多长的时间。因此,用堆保存数据时会得到更大的灵活性。事实上,面向对象的多态性,堆内存分配是必不可少的,因为多态变量所需的存储空间只有在运行时创建了对象之后才能确定。所以堆内存最大的特点就是:堆允许程序在运行时动态地申请某个大小的内存空间。

在JVM 中,堆(Heap)是可供各条线程共享的运行时内存区域,也是供所有类实例和数组对象分配内存的区域。Java堆在虚拟机启动的时候就被创建,它存储了被自动内存管理系统(Automatic Storage Management System,也即是常说的“Garbage Collector(垃圾收集器)”)所管理的各种对象,这些受管理的对象无需,也无法显式地被销毁。Java堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。Java堆所使用的内存亦不需要保证是连续的。

JVM 实现应当提供给程序员或者最终用户调节Java堆初始容量的手段,对于可以动态扩展和收缩Java堆来说,则应当提供调节其最大、最小容量的手段。在Java 中,要求创建一个对象时,只需用new 关键字及相关的代码即可。执行这些代码时,JVM 会在堆内存中自动进行数据存储空间的分配。

3.2 栈(Stack)

Java虚拟机栈(Java Virtual Machine Stacks),该区域属于线程私有,它的生命周期也与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,栈它是用于支持续虚拟机进行方法调用和方法执行的数据结构。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入了方法表的Code属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

在Java虚拟机规范中,对这个区域规定了两种异常情况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
  2. 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这两种情况存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。在单线程的操作中,无论是由于栈帧太大,还是虚拟机栈空间太小,当栈空间无法分配时,虚拟机抛出的都是StackOverflowError异常,而不会得到OutOfMemoryError异常。而在多线程环境下,则会抛出OutOfMemoryError异常。

java程序执行过程

JVM为每个新创建的线程都分配一个堆栈.也就是说,对于一个Java程序来说,它的运行就是通过对堆栈的操作来完成的。JVM对堆栈只进行两种操作:以帧为单位的压栈和出栈操作(FILO,先进后出,这里不理解的朋友后续我会在数据结构篇中细讲)。

  1. 局部变量表(Local Variable Table),用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。
  2. 操作数栈(Operand Stack),数据运算的地方,可理解为java虚拟机栈中的一个用于计算的临时数据存储区。存储的数据与局部变量表一致含int、long、float、double、reference、returnType,操作数栈中byte、short、char压栈前(bipush)会被转为int。大多数指令都在操作数栈弹栈运算,然后将结果压栈。
  3. 动态链接(Dynamic Linking),指向运行时常量池的引用,因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。
  4. 方法返回地址(Return Address),当一个方法执行后,返回到方法被调用的位置,确保程序继续执行。
  5. 其它附加信息

补充:

  1. 局部变量表,用于存放方法参数和方法内部定义的局部变量。在编译期由Code属性中的max_locals确定局部变量表的大小。 局部变量表的容量以变量槽(Variable Slot)为最小单位。虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个Slot占用32位长度的内存空间”是有一些差别的,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位虚拟机中的一致。
  2. 关于操作数栈想更深入了解的朋友,可以网上查阅相关的操作指令结合反编译class文件进行解读,反编译指令:javap -c xxx.class
  3. 在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递
  4. 当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

3.3 本地方法栈

本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方发展的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。

3.4 方法区

方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域。在方法区中,存储了每个类的信息(包括类的名称、方法信息、字段信息)、静态变量、常量以及编译器编译后的代码等。

方法区的组成 说明
类型信息 类型的完整有效名(包含直接父类),类型的修饰符,这个类型直接接口的一个有序列表等。
常量池(Constant Pool) jvm为每个已加载的类型都维护一个常量池。常量池就是这个类型用到的常量的一个有序集合,包括实际的常量(string,integer, 和floating point常量)和对类型,域和方法的符号引用。池中的数据项象数组项一样,是通过索引访问的。因为常量池存储了一个类型所使用到的所有类型,域和方法的符号引用,所以它在java程序的动态链接中起了核心的作用。
域(Field)信息 jvm必须在方法区中保存类型的所有域的相关信息以及域的声明顺序,域的相关信息包括:域名、域类型、域修饰符(public, private, protected,static,final,volatile, transient的某个子集)
方法(Method)信息 同域信息一样包括声明顺序,包括:方法名,方法的返回类型(或 void),方法参数的数量和类型(有序的) ,异常表,方法的修饰符(public, private, protected, static, final, synchronized, native, abstract的一个子集)除了abstract和native方法外,其他还有保存方法的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小
类变量 类变量被类的所有实例共享,即使没有类实例时你也可以访问它。这些变量只与类相关,所以在方法区中,它们成为类数据在逻辑上的一部分。在jvm使用一个类之前,它必须在方法区中为每个non-final类变量分配空间。
对类加载器的引用 如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。
除了常量外的所有静态(static)变量
其它 以上例子并不全,感兴趣的朋友可以继续深入研究,欢迎指正、探讨交流

补充:

  1. 在java源代码中,完整有效名由类的所属包名称加一个".",再加上类名组成。例如,类Object的所属包为java.lang,那它的完整名称为java.lang.Object,但在类文件里,所有的"."都被斜杠“/”代替,就成为java/lang/Object。完整有效名在方法区中的表示根据不同的实现而不同。
  2. 常量池(Constant Pool) ,是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

3.5 程序计数器

程序计数器(Program Counter Register),也有称作为PC寄存器,是一个记录着当前线程所执行的字节码的行号指示器。程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址),当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一条线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以说程序计数器是每个线程所私有的。

补充:

  1. 在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;
  2. 执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。
  3. 如果线程执行的是native方法,则程序计数器中的值是undefined。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章