Java疑难杂问②

本文探讨了Java中的内存管理,包括JVM堆的线程安全实现、Volatile的内存语义、JVM内存分配担保机制以及线程启动方法的区别。此外,还介绍了Java异常与错误的区别、构造函数的重载以及其他Java核心概念,如静态变量、最终变量和对象生命周期等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JVM里new对象时,堆会发生抢占吗?如何设计JVM的堆的线程安全的?

  会,假设JVM虚拟机上,每一次new 对象时,指针就会向右移动一个对象size的距离,一个线程正在给A对象分配内存,指针还没有来的及修改,另一个为B对象分配内存的线程,引用这之前的指针指向,这就发生了抢占,也被称为指针碰撞。
  TLAB的实现是给每个线程分配私有的指针,存对象的内存空间还是给所有线程访问,其它线程无法在这个区域分配,保证堆的线程安全。Thread Local Allocation Buffer,线程本地分配缓存
JVM在内存新生代Eden Space中开辟了一小块线程私有的区域TLAB(Thread-local allocation buffer)。在Java程序中很多对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有,所以没有锁开销。也就是说,Java中每个线程都会有自己的缓冲区称作TLAB,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。

java异常和错误的区别

  在java中,异常和错误同属于一个类:Throwable。Exception(异常)是应用程序中出现的可预测,可恢复的问题。Exception分为两类:非运行时异常和运行时异常。运行时异常都是 RuntimeException 类及其子类异常,如 NullPointerException、IndexOutOfBoundsException 等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般由程序逻辑错误引起。非运行时异常是指 RuntimeException 以外的异常,程序就不能编译通过。IOException和ClassNotFoundException 等以及用户自定义的 Exception 异常。Error(错误)大多表示运行应用程序时比较严重的错误。大多错误与编程者编写的程序无关,可能是代码运行时jvm出现的问题。例如OutOfMemoryError。

Thread使用start和run方法启动线程有什么区别

  调用start方法启动,使用start方法才真正实现了多线程运行,因为这个时候不用等待run方法执行完成就可以继续执行下面的代码,真正实现多线程。因为thread线程有5种状态,创建-就绪-运行-阻塞-死亡这五种,调用start方法就是就绪这一步,因为这个时候线程并没有立即的执行,而是得等待,等到cpu有空闲的时候,才会执行线程里面的run方法,等run方法执行完了,线程就结束了。
  如果直接使用thread执行run方法,因为run方法是thread里面的一个普通的方法,所以直接调用run方法,这个时候它是会运行在主线程中的,因为这个时候程序中只有主线程一个线程,所以如果有两个线程,都是直接调用的run方法,那么它们的执行顺序一定是顺序执行,所以这样并没有做到多线程的这种目的。

public synchronized void start() {
    //这里private volatile int threadStatus = 0;初始化的时候就是0
    //如果这里不为0的话就抛异常
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    //把当前线程加入到线程组中
    //private ThreadGroup group;就是这么个东西
    group.add(this);

    //初始化标记位未启动
    boolean started = false;
    try {
        start0();
        //标识为启动状态
        started = true;
    } finally {
        try {
            //如果没开启,标识为启动失败
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {

        }
    }
}

start0()方法用native修饰符,表示调用本机的操作系统函数,说明多线程需要机器底层的支持。

Volatile的内存语义

内存语义 是指:在多线程或处理器中用来控制存取共享内存位置或者说是在更高层次上共享变量的处理逻辑。

volatile的happens-before原则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

volatile的内存语义
volatile的内存语义即是用来保证volatile的happens-before原则。

  • volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
  • volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量

volatile内存语义的实现
  为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
  内存屏障是一种barrier指令类型,它导致CPU或编译器对barrier指令前后发出的内存操作执行顺序约束。即在barrier之前的内存操作保证在barrier之后的内存操作之前执行。

内存屏障有以下4种:

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。 在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

为了实现volatile内存语义,Java编译器会这样使用内存屏障:内存屏障 —实现—> 内存语义 —保证—> happens-before原则

JVM内存分配担保机制

  在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则此次Minor GC是安全的如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
  为什么要进行空间担保?是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活,而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。
  在不同的GC机制下,也就是不同垃圾回收器组合下,担保机制也略有不同。在Serial+Serial Old的情况下,发现放不下就直接启动担保机制;在Parallel Scavenge+Serial Old的情况下,却是先要去判断一下要分配的内存是不是>=Eden区大小的一半,如果是那么直接把该对象放入老生代,否则才会启动担保机制。

Java序列化SerialVersion

  内存中的数据对象只有转换二进制流才可以进行数据持久化和网络传输,例如内存中的对象状态保存到一个文件中或者数据库中时候,将数据对象转换成二进制流的操作叫做序列化,反之叫做反序列化。
  Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException。

static、static final、final的区别

final:

final修饰类,类不能够被继承
final修饰方法,方法不能够被重写
final修饰基本类型变量,该变量被赋值后不能再被修改
final修饰引用,该引用不可以再指向其它的对象

static:

static可以修饰:属性,方法,代码段,内部类(静态内部类或嵌套内部类)
static修饰的属性的初始化在编译期(类加载的时候)
static修饰的属性所有对象都只有一个值
static修饰的属性、方法、代码段跟该类的具体对象无关,不创建对象也能调用static修饰的属性、方法等

static final:static修饰的属性强调它们只有一个,final修饰的属性表明是一个常数(创建后不能被修改)。static final修饰的属性表示一旦给值,就不可修改,并且可以通过类名访问。static final也可以修饰方法,表示该方法不能重写,可以在不new对象的情况下调用。

Java为什么需要 JVM

  JVM用来模拟通用的计算机,有着一套虚拟的完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM是一种规定好的标准规范, 定义了.class文件在其内部运行的相关标准和规范。Java程序可以跨平台,Java编译时,并不直接翻译为某平台的0101指令,而是翻译为中介格式的位元码(byte code)。Java 的原始码文件格式名为*.java,经过编译器翻译过后,会变成*.class的格式文件位元码。如果想要执行这个位元码档案,目标平台上必须安装有JVM,JVM会将位元码翻译为相应平台支持的语言。
  对于Java程序而言,其实它只认识JVM,而对于JVM而言,位元码文件就是它的可执行文件,即格式为.class的文件。Java代码程序,并不用理会真正执行于哪个平台之上,它只要知道如何执行于JVM之上就可以了,至于JVM实际上如何与底层平台作沟通,则是JVM自己的事。

Java类和对象的区别

  1. 类是一个抽象的概念,它不存在于现实中的时间/空间里,类只是为所有的对象定义了抽象的属性与行为。对象是类的一个具体,它是一个实实在在存在的东西
  2. 类是一个静态的概念,类本身不携带任何数据。当没有为类创建任何对象时,类本身不存在于内存空间中。对象是一个动态的概念。每一个对象都存在着有别于其它对象的属于自己的独特的属性和行为。对象的属性可以随着它自己的行为而发生改变
  3. 类是对象的模板,对象是类的实例。类只有通过对象才可以使用,而在开发之中应该先产生类,之后再产生对象。类不能直接使用,对象是可以直接使用的

接口和类的区别

接口不能直接实例化;接口不包含方法的实现;接口可以多继承,类只能单继承。

UUID

  UUID(通用唯一识别码)的目的是让分布式系统中的所有元素都能有唯一的识别信息。如此一来,每个人都可以创建不与其它人冲突的 UUID,就不需考虑数据库创建时的名称重复问题。UUID 是由一组32位数的16进制数字所构成,理论上的总数为16^ 32=2^128。通过java.util.UUID的randomUUID()方法生成UUID。

java可变参数列表的实现

1、传入数组对象或者集合,只要把需要传入的参数放到一个数组里面或者集合里面,再把数组或者集合当做参数传入即可

2、在参数类型后面跟三个点(…)然后在写上参数名

public class TestArgs2 {
    static void printArray(Object... args){
        for (Object obj : args) {
            System.out.println(obj);
        }
    }

public static void main(String[] args) {
    printArray(new Object[]{
         new Integer(123),new Float(3.14),new Double(123.123)
    });

3、当传入的参数类型不一致的时候也可以实现传入可变参数列表,可变参数也可以不传

public class TestArray3 {
    static void printArray(int i,String... s){
        for (String str : s) {
            System.out.println(i + "---" + str);
            i++;
        }
        System.out.println(i);
    }
    public static void main(String[] args) {
        printArray(1, "11","22","33");
    }
}

局部性原理

局部性通常有两种不同的形式:时间局部性空间局部性

时间局部性
  在一个具有良好的时间局部性的程序中,被访问过一次的存储器位置很可能在不远的将来会被再次访问。

空间局部性
  在一个具有良好空间局部性的程序中,如果一个存储器位置被访问了一次,那么程序很可能在不远的将来访问附近的一个存储器位置。

局部性原理的应用
  局部性原理对硬件和软件的设计都有着极大的影响,从硬件到操作系统、再到应用程序,它们的设计都用到了局部性原理。正是由于局部性原理的存在,在硬件层通过引入高速缓存存储器能够在很大程度上提升程序运行的速度。操作系统的虚拟地址空间的技术,以及缓存磁盘文件系统中最近被使用的磁盘块等都用到了局部性原理。在应用程序设计中应用到的缓存思想。

Final修饰的int、String、Map可以改变吗

  final修饰的类不能被继承,修饰的方法不能重写(可以重载),修饰的变量不可变,注意这里的不可变是指引用不可变,值是可变的。所以final修饰的Map调用put方法是只是给内存地址加了东西,而没有改变指向Map这个对象的引用,故可以正常执行,但是如果map = new HashMap()时,它是不行的,因为这样改变了引用。然而String不同,String设置的时候就是值不可变即内容不可变。
  所以当写a = 2 时候就已经新创建了一个引用指向2这个值而不是改变原来的地址内容。这样的好处是更安全,方便利用字符串常量池提高效率字符串常量池1.6之前放在方法区中,之后版本移到堆中。

cpu的缓存一致性协议

  cpu中cache缓存的好处是提高运行速度,带来的问题是如何保证缓存一致性。cpu的内置缓存保证与主内存一致性的方法有2种:1.总线锁(锁住总线,同步cpu缓存与内存中的脏数据,效率低) 2.缓存一致性协议(MESI)

MESI协议缓存状态
MESI 是指4种状态的首字母。每个Cache line(缓存行:缓存存储数据的单元)有4个状态,可用2个bit表示,它们分别是:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

注意:
  对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。
  从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

java程序的执行过程

  java代码经历三个阶段:源代码阶段(Source)->类加载阶段(ClassLoader)->运行时阶段(Runtime)。Java代码整个执行过程:Java源程序(.java)经过Java编译器(javac.exe)之后编译成JVM文件(.class文件),JVM将每一条要执行的字节码通过类加载器ClassLoader加载进内存,再通过字节码校验器的校验,Java解释器翻译成对应的机器码,最后在操作系统解释运行。JVM其向上屏蔽了操作系统的差异,使java能够实现跨平台。
类加载机制
对象/类生命周期

  当程序要使用某个类时,如果该类还未被加载到内存中,则系统会通过加载、连接、初始化三步来实现对这个类进行初始化:
  加载是将class文件读入内存并为之创建一个Class对象(任何类被使用时系统都会创建且只创建一个Class对象)。JVM进行类加载阶段需要完成以下三件事情:
  1. 通过一个类的全限定名称来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在java堆中生成一个代表这个类的java.lang.Class对象, 作为方法区这些数据的访问入口

类的加载的最终产品是位于堆区中的Class对象, Class对象封装了类在方法区内的数据结构并且向Java程序员提供了访问方法区内的数据结构的接口。类的加载时机:
  1. 创建类的实例
  2. 使用类的静态变量或者为静态变量赋值
  3. 调用类的静态方法
  4. 使用反射方式来强制创建某个类或接口对应的java.lang.Class对象
  5. 初始化某个类的子类
  6. 直接使用java命令来运行某个主类

连接就是将类的二进制数据合并到JRE中。连接分为以下三步:
验证 检查载入Class文件数据的正确性
准备 该阶段正式为类变量分配内存并设置类变量初始值
解析 将类的二进制数据中的符号引用替换为直接引用

  初始化是对类的静态变量、静态代码块执行初始化操作。初始化为类的静态变量赋予正确的初始值, JVM负责对类进行初始化, 主要对类变量进行初始化, 在Java中对类变量进行初始值设定有两种方式:声明静态变量(类变量)时指定初始值、使用静态代码块为类变量指定初始值。

最后是Runtime运行时阶段。

java应用cpu占用过高问题

方法1:
1.jps 获取Java进程的PID。
2.jstack pid >> java.txt 导出CPU占用高进程的线程栈。
3.top -H -p PID 查看对应进程的哪个线程占用CPU过高。
4.echo “obase=16; PID” | bc 将线程的PID转换为16进制,大写转换为小写。
5.在第二步导出的Java.txt中查找转换成为16进制的线程PID。找到对应的线程栈。
6.分析负载高的线程栈都是什么业务操作。优化程序并处理问题。

方法2:
1.使用top 定位到占用CPU高的进程PID
top
通过ps aux | grep PID命令
2.获取线程信息,并找到占用CPU高的线程
ps -mp pid -o THREAD,tid,time | sort -rn
3.将需要的线程ID转换为16进制格式
printf “%x\n” tid
4.打印线程的堆栈信息
jstack pid |grep tid -A 30

一个应用占用CPU很高,除了确实是计算密集型应用之外,通常原因都是出现了死循环。

JDK和JRE区别

  JDK:java开发工具包。JDK可以支持Java程序的开发,包括编译器(javac.exe)、开发工具(javadoc.exe、jar.exe、keytool.exe、jconsole.exe)和更多的类库(如tools.jar)等。
  JRE:java运行时环境。JRE可以支撑Java程序的运行,包括JVM虚拟机(java.exe等)和基本的类库(rt.jar等)。

构造函数是否可以重载

  构造函数可以被重载,析构函数不可以被重载。因为构造函数可以有多个且可以带参数,而析构函数只能有一个,且不能带参数。
  析构函数:当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。析构函数往往用来做“清理善后” 的工作(例如在建立对象时用new开辟了一片内存空间,delete会自动调用析构函数后释放内存)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值