又要快到一年一度的金三银四,开始复习啦~!
每天一点点。。
目录
一、内存模型设计
二、synchronized和ReentrantLock的区别
三、垃圾回收机制
四、优化垃圾回收机制
4.1 了解应用需求
4.2. 调整堆大小
4.3. 减少对象分配
4.4. 使用合适的垃圾回收器
4.5. 生成器调优
4.6. 监控和分析
4.7. 调整垃圾收集器参数
4.8. 新生代与老年代比例
4.9. 监控与分析
4.10. 代码层面优化
4.11. 处理内在泄漏
4.12. 考虑元空间大小
4.13. 使用专业的分析工具
4.14. 使用JVM内置的GC优化建议
4.15. 理解Java内存模型
4.16. 晋升失败与提前晋升
4.17. 使用Parallel GC的自适应调节特性
4.18. 类加载器的内存管理
4.19. 慎用finalize()方法
4.20. 避免调优过度
4.21. 确保资源的及时释放
4.22. 阅读最新的文档和社区实践
4.22. CPU与内存的平衡
4.23. 代码优化
4.24. 复合型调优
4.25. 了解并选择最新的垃圾回收器
4.26. 考虑使用Off-Heap内存
4.27. 内存分页策略
4.28. 使用大页内存
4.29. GC触发策略
4.30. 对象分配策略
4.31. 协同工作
五、反射原理
六、反射的用途
六、异常和错误的区别
七、异常处理机制
八、线程安全的单例模式及注意事项
8.1、懒汉式(线程安全)
8.2、饿汉式(线程安全)
8.3、静态内部类(推荐)
8.4、枚举(最佳方法)
8.5、双重校验锁(线程安全)
8.6、这个实现方式具有以下特点
8.7、实现单例模式注意点
一、内存模型设计
如上图所示“Java运行时数据区”。 Java的内存模型主要包括堆、栈、方法区和本地方法栈几个关键部分:
- 堆(Heap): 这是Java内存管理中最大的一块,被所有线程共享。在堆中主要存放对象实例和数组。
- 栈(Stack): 每个线程运行时都会创建一个栈,用于存放局部变量、操作数栈、动态链接和方法出口等。栈的生命周期和线程同步。
- 方法区(Method Area): 同样被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量等数据。
- 本地方法栈(Native Method Stack): 专门用于处理本地方法的调用。
- 此外,Java内存模型还包括程序计数器等组件,共同构成了Java的运行时数据区。
Java内存模型(Java Memory Model,简称JMM)在多线程编程中起到非常关键的作用,主要关注以下几个方面:
线程间通信:JMM定义了线程如何通过主内存(堆内存)来交换信息。每个线程都有自己的工作内存,它包含了变量的主内存副本。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,然后再有可能地同步回主内存。
同步规则:JMM为Java中的同步提供了一套规则,它决定了一个变量的写入何时对其他线程可见,以及线程何时能读到其他线程写入的值。
其中,JMM中两个最重要的方面就是可见性和有序性:
可见性:指一个线程对共享变量值的修改,最终会被其他线程看到。为了保证可见性,JMM利用了
volatile
关键字以及各种锁机制(包括synchronized
),这些同步措施能够确保变量值的及时更新到主内存以及从主内存的及时读取。有序性:即程序执行的顺序,按照代码顺序执行的原则。由于编译器和处理器可能对操作进行重排序优化,但JMM保证在单线程环境中不改变程序执行的语义,同时在多线程环境中保持合理的有序性,防止指令重排影响程序正确性。
volatile
关键字和锁同样也能够限制重排序。原子性:指一个或多个操作全都执行,或者都不执行,不能中断,也就是说,在一个操作执行过程中不会被其他线程干扰。比如在Java中,对除了
long
和double
以外的基本数据类型变量的读取和写入操作都是原子操作。内存屏障:JMM通过内存屏障(Memory Barrier)来实现有序性和可见性。内存屏障是CPU指令,它能强制前一操作的结果写回到主内存中,或者使得缓存失效从而从主内存中重新读取最新数据。
happens-before原则:JMM定义了一个重要原则,即"happens-before",这个原则为了程序员和编译器的写和读操作建立了一个偏序关系。如果一个操作happens-before另一个操作,那么第一个操作对内存的修改将对第二个操作可见,并且第一操作的排序在第二操作之前。
二、synchronized和ReentrantLock的区别
都是Java中的并发控制工具,它们都是用于控制多个线程对共享资源的访问,以避免出现并发问题(实现线程同步的工具。二者的作用都是让某个代码块在同一时间只能被一个线程访问。)。
synchronized和ReentrantLock都是用于控制多线程访问同步资源的机制,它们以下不同点:
- 锁的实现方式: synchronized是依赖于JVM实现的,而ReentrantLock是Java提供的API。
锁的公平性: ReentrantLock可以指定为公平锁或非公平锁,而synchronized只能是非公平锁。
ReentrantLock 是一种显式的锁,需要手动获取和释放锁,允许多个条件变量(Condition)控制同时被唤醒的线程集合,具有可重入性和公平性等特性。ReentrantLock 的功能更加强大,允许在等待时间内尝试获取锁,并且允许中断等待线程。synchronized 是一种隐式的锁,JVM 会自动获取和释放锁,不允许自定义条件变量,也不具备公平性,只具备可重入性。使用 synchronized 时,程序员不需要手动进行任何锁的处理,这是一种便利和简单的方式。
锁的灵活性:ReentrantLock提供了更多的功能,比如可以中断等待锁的线程,获取等待锁的线程列表,还可以尝试获取锁。
ReentrantLock 更加灵活和强大,也更适合复杂的同步需求;而synchronized 更加便捷,也更适合简单的同步需求。性能:在JDK1.6之后,synchronized的性能得到了很大优化,和ReentrantLock比较接近。
在多线程并发访问时, ReentrantLock 的性能要优于 synchronized,尤其是在竞争激烈的情况下,由于 ReentrantLock 具有更强的可控性和灵活性,相对于 synchronized 能够更好地处理死锁、饥饿等问题。但是 ReentrantLock 对于代码的可读性和维护成本也更高一些。锁的细粒度控制: ReentrantLock可以更精确的控制锁,有更丰富的锁操作方法。
synchronized 时,锁的粒度是代码块或方法。
ReentrantLock 时锁的粒度可以更细,可以自由地选择加锁和解锁的时机和位置。
Java中还有以下常用的其他的并发控制工具:
- Semaphore:信号量是一种计数信号,它用于控制对共享资源的访问。信号量可以看作是一个具有整数值的计数器,该计数器表示可用资源的数量。当线程需要访问资源时,它需要获取信号量,如果信号量的值为0,则线程会被阻塞,直到其他线程释放信号量。
- CountDownLatch:倒计时门闩是一种同步工具,它允许一个或多个线程等待其他线程完成一系列操作。它有一个计数器,初始化为一个正数,每次有线程完成操作时,计数器会减1。当计数器达到0时,所有等待的线程都会被唤醒。
- CyclicBarrier:循环屏障是一个同步辅助类,它允许一组线程互相等待,直到所有线程都达到某个状态后再一起执行。它类似于一个门闩,但与门闩不同的是,它可以重复使用。
- Exchanger:交换器是一个同步工具,它允许两个线程交换对象。两个线程可以在交换器上交换对象,然后继续执行不同的任务。
- Phaser:相控器是Java 7引入的一个新的并发工具,它是一个同步器,用于在多阶段任务中协调多个线程的执行。相控器可以用于替代CyclicBarrier和CountDownLatch的组合,提供更灵活的同步机制。
三、垃圾回收机制
Java的垃圾回收(Garbage Collection,GC)是指Java虚拟机(JVM)进行的自动内存管理过程,其目标是识别并丢弃不再使用的对象以释放并回收内存。Java内存管理是通过GC自动完成的,无需开发者手动释放内在,这减少了内存泄漏风险和无效内存引用带来的问题。
其核心原理和步骤如下:
- 标记: 首先标记出所有可达的对象。
- 删除/整理: 删除所有不可达的对象或者将存活的对象移动到连续的空间内,释放内存空间。
- 垃圾回收器类型: Java提供了多种垃圾回收器,如Serial、Parallel、CMS、G1等,每种回收器适用于不同的场景和需求。
- Minor GC和Major GC: Minor GC清理年轻代,而Major GC通常清理老年代,Full GC会清理整个堆空间。
主要垃圾回收机制包括:
- 标记-清除(Mark-Sweep):垃圾回收器首先标记所有活动对象,然后清除那些未被标记的对象,释放它们所占用的内存空间。
- 复制(Copying):这种方法将内存分为两部分,每次只使用其中的一部分。垃圾回收时,只复制活动对象到另一半内存区,然后清除当前使用的所有内存。
- 标记-整理(Mark-Compact):在标记活动对象之后,不直接清除未标记对象,而是将所有存活的对象移动到内存的一端,然后清除剩余内存。
- 分代垃圾回收:Java虚拟机将对象按生命周期分为几代。新生代(Young Generation)存放新创建的对象,老年代(Old Generation)存放生命周期较长的对象。还有一个持久代(Permanent Generation),用于存储类和方法的元数据信息,不过在Java 8中,它被称为元空间(Metaspace)
- 增量垃圾回收:增量GC试图将GC的工作分解成小块,通过分散GC工作负载以减少每次GC停顿时间。
- 并行与并发垃圾回收:在并行GC中,多个回收线程并行工作以提高GC的效率,而并发GC则允许GC线程和应用程序线程同时运行。
对象的可达性:
- 可达对象:通过一系列的引用链,可以从根对象(比如虚拟机栈、本地方法栈中的本地变量、方法区中的类静态属性、方法区中的常量等)到达的对象。
- 不可达对象:当一个对象没有任何引用链与根对象相连时,它就可能被认为是不可达的。
垃圾收集器:
- Serial:单线程收集器,适用于单核处理器的场景。Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。单线程的收集器,收集垃圾时,必须stop the world,使用复制算法。
- Serial Old收集器:是Serial收集器的老年代版本,单线程收集器,使用标记整理算法。它主要有两大用途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用,另一种用途是作为CMS收集器的后备方案。启用命令: -XX:+UseSerialGC -XX:+UseSerialOldGC
Parale Scavenge收集器:Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
Parallel Scavenge收集器关注点是达到一个可控的吞叶量,所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。如果虚拟机总共运行100分钟,其中垃圾花掉1分钟,香叶量就是99%。
新生代采用复制算法,老年代采用标记-整理算法。
Parallel Old收集器:Parallel Scavenge收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及CPU资源的场合,都可以优先考虑 Parallel Scavenge收集器和Parallel Old收集器(JDK8默认的新生代和老年代收集器)。
启用命令 -XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代)
ParNew收集器:ParNew收集器其实跟Parallel收集器很类似,区别主要在于它可以和CMS收集器配合使用。新生代采用复制算法,老年代采用标记-整理算法。
- Parallel / Throughput:多线程收集器,重点在于增加吞吐量。
- CMS (Concurrent Mark-Sweep):收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。整个过程分为5个步骤:初始标记->并发标记->重新标记->并发清理->并发重置。
- G1 (Garbage-First):旨在提供高吞吐量及低延迟,适用于多核处理器和大内存的服务器环境。标记整理算法实现,运作流程主要包括以下:初始标记,并发标记,最终标记,筛选回收,不会产生空间碎片,可以精确地控制停顿。
- ZGC (Z Garbage Collector) 和 Shenandoah:针对减少暂停时间的高性能垃圾回收器,可以处理大内存容量,并减少GC停顿时间至几毫秒。
垃圾回收过程:
- Minor GC:清理新生代,发生频率比较高,但是停顿时间短。
- Major / Full GC:清理老年代,发生频率低,但停顿时间长。
四、优化垃圾回收机制
Java中优化垃圾回收(Garbage Collection, GC)过程通常涉及到对JVM调优(Tuning)的一系列活动。以下是一些通用的优化策略:
4.1 了解应用需求
- 吞吐量:如果应用程序需要最大化CPU工作时间来完成应用程序逻辑,那么可能需要优化GC以提高吞吐量。
- 延迟:如果应用程序是交互式的,并且您需要避免长时间的GC停顿,那么可能需要关注减少垃圾回收的延迟。
4.2. 调整堆大小
- 避免设置过小的堆,可能会导致频繁的垃圾回收。
- 避免设置过大的堆,可能会导致垃圾回收的停顿时间过长。
- 使用JVM参数
-Xmx
和-Xms
来调整最大和初始堆大小。
4.3. 减少对象分配
- 尽可能重用对象,避免频繁创建和销毁对象。
4.4. 使用合适的垃圾回收器
- 根据应用的特点(如交互式还是批处理、小堆还是大堆等)和对吞吐量和延迟的需求来选择不同的垃圾收集器。
- 根据应用的需求选择合适的垃圾回收器,如G1、CMS等。
4.5. 生成器调优
- 调整新生代与老年代的比例,根据应用特性进行调整。
4.6. 监控和分析
- 使用JVM监控工具(如jvisualvm, jconsole)定期监控和分析GC日志,找出性能瓶颈。
4.7. 调整垃圾收集器参数
- 例如,可以调整并行收集器的线程数 (
-XX:ParallelGCThreads
) 或 G1 收集器的停顿时间目标(-XX:MaxGCPauseMillis
)。
4.8. 新生代与老年代比例
- 使用
-XX:NewRatio
来调整新生代(Young Generation)与老年代(Old Generation)的大小比例。
4.9. 监控与分析
- 利用监控工具(如JConsole, VisualVM, Flight Recorder等)进行实时监控。
- 分析垃圾回收日志,可以用
-Xloggc:<file>
参数启用日志记录。
4.10. 代码层面优化
- 避免大量临时对象的创建,减少不必要的对象分配。
- 使用对象池,以减少对象的创建和销毁。
- 优化数据结构,减少内存开销。
4.11. 处理内在泄漏
- 确保不必要的对象引用被及时清除,否则这些对象将永远不会被GC回收。
4.12. 考虑元空间大小
- 对于Java 8以上版本,适当配置元空间大小(使用
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
)。
要进行有效的垃圾回收优化,理解应用的工作负载和性能需求非常重要。通过监控、调整以及测试,得到合适的JVM设置组合是一个反复迭代的过程。在调优堆内存和垃圾收集器设置时,注意避免引入不必要的复杂性,总是基于实际的性能数据进行决策。
在优化Java垃圾回收的过程中,你应该遵循一个迭代的过程,它包括监测、分析和调整。以下步骤可以帮助你更细致地进行垃圾回收优化:
4.13. 使用专业的分析工具
- GC日志分析工具:分析和解读GC日志文件,比如使用GCViewer或GCEasy等工具。
- 剖析器(Profiler):使用专业的剖析器分析运行时的内存使用情况,例如YourKit、JProfiler等,以帮助找出内存泄漏和频繁进行GC的代码段。
- 内存泄漏检测工具:确定内存泄漏的根源,比如使用Eclipse Memory Analyzer Tool (MAT)。
4.14. 使用JVM内置的GC优化建议
- JVM有时可以提供关于如何调整GC以提高性能的建议。这些建议通过JVM的日志选项被记录下来。
- 使用
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution
等参数来打印GC详细信息。
4.15. 理解Java内存模型
- 了解Java内存模型有助于理解不同类型的引用(强、软、弱、虚)是如何影响GC行为的。
- 了解对象的生命周期和不同GC算法是如何处理不同代(或区域)中的对象的。
4.16. 晋升失败与提前晋升
- 晋升失败(Promotion Failure):对象在新生代中因为空间不足而不能晋升到老年代时的情况,可以通过调整参数进行优化。
- 提前晋升(Premature Promotion):可以通过调节
-XX:MaxTenuringThreshold
参数来控制对象从新生代晋升到老年代的时机。
4.17. 使用Parallel GC的自适应调节特性
- Parallel GC可以通过
-XX:+UseAdaptiveSizePolicy
指令来自动调整堆大小、Eden区与Survivor区的比例等。
4.18. 类加载器的内存管理
- 类加载器会影响类的生命周期,不正确的使用可能导致内存泄露。确保不再使用的类被卸载,这对长时间运行的应用尤其重要。
4.19. 慎用finalize()方法
finalize()
方法的调用是不可预测的,且可能导致额外的GC开销。尽量使用try-with-resources和其他清理机制替代finalize()
。
4.20. 避免调优过度
- 过度调优可能反而影响性能,每一次调整后都需要进行全面的性能测试来确保实际的收益。
4.21. 确保资源的及时释放
- 关闭文件、套接字和数据库连接等资源。
4.22. 阅读最新的文档和社区实践
- GC和JVM的特性会随着新版本不断演进,关注最新的变化可以带来新的优化机会。
记住,没有一成不变的规则来适用于所有的场景。每个垃圾回收优化都是独特的,需要根据应用程序的特定行为和需求进行调整。
继续深入优化Java垃圾回收,可以考虑以下更先进或者更详细的策略:
4.22. CPU与内存的平衡
- 优化GC通常是在CPU资源和内存资源之间寻求平衡。选择合适的GC策略,确定是否需要快速响应(较低的延迟)或最大化吞吐量。
4.23. 代码优化
- 优化热点代码,减少创建不必要的临时对象,例如通过循环外提取对象的创建,使用基本类型代替包装类型等。
- 检查集合类使用模式,减少过度分配(例如,使用合适初始容量的HashMap)。
4.24. 复合型调优
- 综合考虑应用程序中各种数据类型的生命周期和访问模式,如长生命周期的大对象、短生命周期的小对象,以便选择或调整GC策略。
4.25. 了解并选择最新的垃圾回收器
- Java 8引入了G1(Garbage-First)收集器,设计用于减少停顿时间而不牺牲太多吞吐量。
- Java 11引入了ZGC(Z Garbage Collector),它旨在可预测的低延时下支持非常大的堆。
- Java 12及以后版本引入了Shenandoah GC,与ZGC类似,旨在减少GC导致的停顿。
4.26. 考虑使用Off-Heap内存
- 通过使用NIO(New IO)中的Direct ByteBuffer,将一些大缓存移动至堆外内存,减少垃圾回收压力。需要谨慎管理,因为堆外内存的分配与回收不受JVM垃圾回收器的管控。
4.27. 内存分页策略
- 通过对JVM内存分页策略的了解,可以调整以减少页面错误(page fault)和确保内存访问的高效性。
4.28. 使用大页内存
- 对于拥有大内存和需要高吞吐量的应用程序,可以使用Java的大页(HugePages)功能来提高性能。
4.29. GC触发策略
- JVM允许对GC触发条件进行一定程度上的定制,了解何时触发GC可以帮助更好地调整JVM行为以适应应用需求。
4.30. 对象分配策略
- JVM在对象分配时采用的是TLAB(Thread Local Allocation Buffer)分配。通过优化TLAB的大小(使用
-XX:TLABSize
和-XX:+ResizeTLAB
参数),可以减少线程之间的竞争和提高对象分配的效率。
4.31. 协同工作
- 在大型应用中,与系统管理员、开发人员和架构师紧密合作,理解和识别系统的整体性能瓶颈,并合作进行系统性调优。
调优工作是一个持续不断的过程。随着应用程序的发展,它的性能需要不断地进行监控、评估和调整。每次更改后,都应对系统进行全面测试以验证优化是否有效。此外,应用程序逻辑的更改或JVM升级都可能需要重新进行调优工作。因此,持续的性能评估能确保应用始终以期望的性能运行。
五、反射原理
Java反射机制是指在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性。这种动态获取信息以及动态调用对象的方法的功能称为Java语言的反射机制。
主要包括以下内容:
- 获取Class对象的三种方式: 直接通过对象调用getClass()方法,使用Class类的静态方法forName(String className),或者通过类名.class属性。
- 创建对象: 通过Class对象的newInstance()方法创建其对应类的实例。
- 获取方法: 使用Class对象的getDeclaredMethods()方法获取类内定义的所有方法。
- 访问字段: 使用Class对象的getDeclaredFields()方法访问类内定义的字段。
- 调用方法: 通过Method对象的invoke()方法调用具体的方法。
反射机制主要功能和特点:
- 动态获取类信息:可以通过反射机制获得类的所有信息,比如类名、方法信息、字段信息、构造函数信息等。
- 创建对象:可以通过反射机制创建一个类的实例对象,即使你在编写程序时并不知道具体的类名。
- 调用方法:通过反射机制可以调用类的任何定义的方法,即使这个方法是私有的。
- 操作属性:可以通过反射机制访问和修改类的字段,即使这些字段是私有的。
- 反射和泛型:反射机制具备处理泛型的能力,例如在类型擦除后依然能够获得泛型的实际类型参数。
获取注解信息:反射可以用来加载注解(Annotations)信息,这在开发框架和库时尤其有用。
优点:
- 提供了极大的灵活性,程序可以动态加载、探测和使用编译期间完全未知的classes。
- 可以使代码的功能拓展性和通用性更强。
缺点:
- 性能开销:反射涉及类型解析等操作,比直接执行代码要慢。
- 安全问题:反射机制破坏了类的封装性,能够访问私有成员。
- 内部曝露:使用反射代码可以看到类的内部细节,有时这可能并不是你想要的。
Java的反射功能主要集中在java.lang.reflect包内,基本的反射功能可以通过如下几个类来实现:
Class
:代表类的实体,在运行的Java应用程序中表示类和接口。Field
:代表类的成员变量(成员字段)。Method
:代表类的方法。Constructor
:代表类的构造方法。
一个简单的使用反射创建对象和调用方法的示例:
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
public class ReflectionExample {
public static void main(String[] args) {
try {
Class<?> clazz = Class.forName("java.util.ArrayList");
Constructor<?> cons = clazz.getConstructor();
Object instance = cons.newInstance();
Method method = clazz.getMethod("add", Object.class);
method.invoke(instance, "Hello World!");
System.out.println(instance.toString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
在这个例子中,程序在运行时加载了java.util.ArrayList
类,使用它的默认构造函数创建了一个实例,并通过反射调用了add
方法将"Hello World!"
加入到了列表中。这一切都是在没有事先具体知道类信息的情况下完成的。
六、反射的用途
Java中的反射机制允许程序在运行时访问、检测和修改其自身的类和对象的信息。其主要用途包括:
- 运行时类信息获取: 反射可以用来在运行时获取类的信息,如类的方法、字段、构造函数等。
- 动态创建对象: 可以使用反射动态创建对象和调用对象的方法,增加了程序的灵活性。
- 实现通用框架: 许多框架(如Spring)使用反射来实现依赖注入和服务定位。
- 调试和测试: 反射常被用于编写测试框架,可以动态调用私有方法进行测试。
- 突破访问控制: 可以通过反射访问类的私有成员,虽然这违反了封装原则,但在某些特殊情况下非常有用。
- 调试和测试:反射常被用于调试和测试工具中,以访问类的内部信息和状态,进而可以检测和验证类的行为和状态。
开发IDE和工具:集成开发环境(IDE)使用反射显示类和对象的信息,帮助开发人员编码。例如,当你在IDE中查看对象时,它可以使用反射来展示对象的当前状态。
实现泛型APIs:反射用于泛型编程中,例如泛型工厂类中会使用到反射来动态实例化类型参数指定的类。
动态代理:在动态代理中,反射用于动态创建代理类的实例,并且在运行时确定要执行的具体方法。
注解处理:反射机制用于读取注解信息,并动态地处理注解提供的数据。
框架与库的构建:许多流行的Java框架(如Spring和Hibernate)都使用反射来动态地创建对象、调用方法、操作字段等,从而提供更高级别的抽象。
拓展性和插件化:使用反射可以实现拓展性功能,在运行时加载可扩展组件或插件,无需在编译时对它们进行硬编码。
配置和初始化:框架和应用经常从配置文件加载类和方法,并使用反射来创建实例和调用方法,进行初始化过程。
数据映射:反射用于自动化对象和数据源(如数据库、JSON、XML等)之间的映射处理,这是很多ORM(Object-Relational Mapping)工具的基础。
序列化和反序列化:某些序列化库可能会使用反射来动态确定要序列化的字段,并在反序列化过程中创建对象。
代码生成:某些代码生成工具或框架在运行时或编译时使用反射来分析类的结构,进而生成辅助代码或文档。
注:Java的反射机制允许程序在运行时对类和对象进行操作和查询,这确保了Java的强大灵活性和动态性。然而,需要注意,不恰当的使用反射可能导致代码难于理解、维护和性能问题。因此,反射应谨慎使用,并且只在必要时采用。
六、异常和错误的区别
在Java中,异常(Exception)和错误(Error)都是Throwable的子类,它们都代表了程序在执行过程中可以抛出和捕获的异常情况。不过,它们各自用于表示不同类型的异常情况,并且通常有不同的处理方式。
异常(Exception)
异常是指程序运行中可能会遇到的状况,比如用户输入无效、文件未找到等。按照处理方式的不同,异常分为两类:
- 检查性异常(Checked Exceptions):这类异常在编译时会被检查(即,它们必须被捕获和处理,或者在方法签名中声明抛出),否则程序无法编译通过。检查性异常主要是程序无法预期的,但一旦发生需要程序员显式处理的情况。比如,
IOException
、SQLException
等。- 非检查性异常(Unchecked Exceptions):它们包括运行时异常(
RuntimeException
)和其子类,程序员不强制要求捕获。比如,NullPointerException
、IndexOutOfBoundsException
等。这类异常主要是由于程序逻辑错误导致的,应该通过改变代码逻辑来预防。
错误(Error)
错误是表示Java运行时系统的内部错误和资源耗尽错误。错误发生通常意味着严重的问题,它们不是由程序逻辑控制的,比如
OutOfMemoryError
、StackOverflowError
或LinkageError
等。这些情况通常是非程序员能控制的,也不应该被程序捕获。一般而言,错误发生时会导致程序非正常终止。
区别
异常和错误的主要区别在于应对策略和意图:
- 异常:通常是由于程序的错误或外部条件变化导致,可以通过合理的程序设计预知并恢复。
- 错误:通常代表更严重的问题,比如硬件故障或资源不足,程序几乎不可能从这样的问题中恢复。
七、异常处理机制
Java的异常处理机制是一种强大的错误处理方式,它能够帮助程序员有效地控制程序出错时的行为,保证程序的健壮性。在Java中异常处理主要依赖于以下几个关键词:try
, catch
, finally
, throw
和 throws
。
try-catch
异常处理用 try-catch 块实现。代码块放在 try 里面,如果代码块抛出异常,控制流会转移到相应的 catch 块。
try { // 可能会抛出异常的代码 } catch (SomeExceptionType e) { // 处理SomeExceptionType异常的代码 } catch (AnotherExceptionType e) { // 处理AnotherExceptionType异常的代码 }
finally
finally
块包含无论是否发生异常都需要执行的代码,比如资源释放等。finally
块可选,但通常是最佳实践在这里清理资源。try { // 可能会抛出异常的代码 } catch (ExceptionType e) { // 异常处理代码 } finally { // 清理代码,始终执行 }
throw
throw
关键词用来显式抛出一个异常。if (someCondition) { throw new ExceptionType("Error message"); }
throws
throws
关键词用在方法签名中,表示该方法可能会抛出的异常,调用者需要处理这些异常。public void someMethod() throws SomeExceptionType, AnotherExceptionType { // 如果发生了异常,它可能被抛出 }
自定义异常
除了使用Java标准库中定义的异常类之外,可以创建自定义的异常类。自定义的异常类应该扩展
Exception
(或其子类)或RuntimeException
。public class MyException extends Exception { public MyException(String message) { super(message); } }
异常处理策略
异常处理应该遵循一些基本的原则:
- 捕获之时处理之:只在你能够处理异常的时候捕获它。
- 尽早抛出:在你确认无法处理异常时,尽早将它抛出,让调用栈中更高层的方法来处理。
- 具体明确:尽量捕获最具体的异常类别,避免使用过于广泛的
catch (Exception e)
。- 避免不必要的异常使用:如果可以通过条件判断来预防异常,就没有必要用异常来处理这种情况。
- 资源清理:在
finally
块中释放资源,或者使用try-with-resources
结构。Java的异常处理机制是一种强健的错误控制机制,可以帮助程序在面对错误情况时做出适当反应并维持系统稳定运行。正确使用这一机制是编写健壮、可读性好的Java程序的关键部分。
八、线程安全的单例模式及注意事项
Java中,实现线程安全的单例模式通常有以下几种方式:
8.1、懒汉式(线程安全)
使用synchronized关键字同步获取实例的方法,确保只有一个线程可以执行该方法,实现线程安全。但这种方式效率较低。
public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
8.2、饿汉式(线程安全)
实例在类加载时就创建,由于类加载机制保证了线程安全,这种方式简单但可能会导致资源浪费。
public class Singleton { private static Singleton instance = new Singleton(); private Singleton() {} public static Singleton getInstance() { return instance; } }
这种方法在类加载时就创建了实例,所以不存在线程安全的问题,但是可能会在应用启动时就创建实例,浪费资源。
8.3、静态内部类(推荐)
利用类加载机制保证初始化实例时只有一个线程。
public class Singleton { private Singleton() {} private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return SingletonHolder.INSTANCE; } }
当需要获取实例时,会调用
getInstance()
方法,此时才会加载静态内部类并创建实例,在此过程中保证线程安全。
8.4、枚举(最佳方法)
利用枚举保证只有一个实例,并且枚举自身提供了序列化机制和线程安全的保证。
public enum Singleton { INSTANCE; }
8.5、双重校验锁(线程安全)
结合懒汉式和同步锁的优点,只在实例未创建时同步,提高效率。
public class Singleton { private volatile static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
其中,
volatile
关键字确保变量的可见性,synchronized
关键字确保线程安全。双重检查锁定方式可以保证只有一个实例会被创建,并且性能较好。需要注意的是,单例类的构造函数应该是私有的,以防止外部直接创建实例。另外,使用静态工厂方法getInstance()
来获取实例。
8.6、这个实现方式具有以下特点
- 延迟加载(Lazy Initialization):只有在需要时才会创建唯一实例;
- 线程安全:
synchronized
关键字确保只有一个线程能够创建实例;- 性能较好:只在第一次创建实例时需要同步,只有当变量为 null 时才去进行同步判断。
8.7、实现单例模式注意点
1、序列化和反序列化(Serialization):当一个序列化的对象反序列化后,会生成一个新的实例。因此,为了保证单例模式的唯一性,需要在类中添加一个
readResolve()
方法。代码如下:public class Singleton implements Serializable { private static final Singleton INSTANCE = new Singleton(); private Singleton() { if (INSTANCE != null) { throw new IllegalStateException("Already initialized."); } } public static Singleton getInstance() { return INSTANCE; } private Object readResolve() throws ObjectStreamException { return INSTANCE; } }
2、反射(Reflection):使用反射可以绕过Java访问控制,直接调用私有的构造方法,从而创建多个实例。因此,需要在私有的构造方法中增加对重复实例的检查,如果已有实例,则抛出异常。代码如下:
public class Singleton { private static Singleton instance; private Singleton() { if (instance != null) { throw new IllegalStateException("Already initialized."); } // 禁止直接创建对象 } public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
通过考虑以上这些情况,我们可以更加完整地实现一个线程安全、序列化安全、反射安全的单例模式。
当实现单例模式时,还有一些其他的方面需要考虑:
1、多线程性能优化:在上述实现中,使用了双重检查锁定(Double-Checked Locking)来确保线程安全性。但是,在某些旧版的Java虚拟机中,双重检查锁定可能会导致初始化未完全完成的实例被返回,可以使用
volatile
关键字修饰单例实例变量来防止这种情况。public class Singleton { private static volatile Singleton instance; private Singleton() { // 禁止直接创建对象 } public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
2、线程安全选择:除了双重检查锁定,也可以使用其他线程安全的机制,如使用
synchronized
关键字修饰getInstance()
方法,在每个访问点上都进行同步,确保线程安全。public class Singleton { private static Singleton instance; private Singleton() { // 禁止直接创建对象 } public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
或者使用静态内部类实现:
public class Singleton { private Singleton() { // 禁止直接创建对象 } private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return SingletonHolder.INSTANCE; } }
3、容器管理:在某些应用场景下,可以使用容器来管理单例实例,例如使用Spring框架的IoC容器管理单例实例。
额外的考虑因素,可以根据具体情况选择合适的实现方式来保证单例模式的线程安全性和其他特性要求。
当实现单例模式时,还有一些其他的注意事项和技巧可以考虑:
1、延迟初始化:如果单例实例非常庞大或资源密集,可以将其延迟初始化,即在首次使用时才创建实例。这样可以避免在应用启动时就创建实例,提高性能和资源利用率。
2、序列化和反序列化:如果需要将单例实例序列化和反序列化,确保单例的唯一性需要特殊处理。可以通过增加
readResolve()
方法,在反序列化时返回单例实例。private Object readResolve() { return getInstance(); }
3、违背单一职责原则:单例模式常常会成为应用中各个模块访问的"全局变量",这可能导致一些类违背了单一职责原则,即承担了除单例管理之外的其他职责。在设计中应该注意将职责分离,尽量保持类的职责单一。
4、测试困难:由于单例模式的全局访问性和状态共享,可能会给单元测试带来困难。可以使用依赖注入的方式来解决单例依赖的问题,用可控的组件代替单例对象进行测试。
实现单例模式时需要注意线程安全、延迟初始化、序列化和反序列化等问题,同时要避免违背单一职责原则和影响测试的困难。根据具体的应用场景和需求,选择合适的实现方式和技巧。
在使用单例模式时,还需要注意以下几点:
使用时机:单例模式适用于那些需要全局唯一实例的场景,如配置信息、线程池等。如果需要频繁创建销毁对象,或者需要多线程访问同一对象的副本,则不适合使用单例模式。
可扩展性:单例模式虽然能保证只有一个实例,但也意味着扩展性较差。如果需要创建多个扩展对象,则需要修改代码并对现有对象进行重构,同时要保证线程安全性。
内存泄漏:如果实现不当,单例模式容易引起内存泄漏。例如,在单例模式中使用静态类变量来存储实例,如果在某些情况下忘记释放该变量,则会导致实例一直存在,无法被垃圾回收器回收。
单例与多线程并行性:对于某些需要频繁访问的场景,单例模式可能会降低应用的并行性。单例模式只有一个实例,这意味着多个线程在同一时间只能访问该实例的某个方法。如果该方法的执行时间较长,则会导致其他线程等待,进而影响整个应用的性能。
当使用单例模式时,还需要注意以下几点:
线程安全性:在多线程环境下使用单例模式时,要确保线程安全性。可以使用同步机制(如锁或同步块)来保证在多线程环境下只创建一个实例。另外,还可以考虑使用双重检查锁(Double-checked locking)或者使用静态内部类的方式来实现延迟初始化。
全局变量的滥用:单例模式往往会导致实例对象变成全局变量,可以在应用中随处访问。这可能会使代码变得难以维护和理解,并且可能带来意想不到的副作用。因此,在使用单例模式时,应该慎重考虑对象的可见性和访问权限,并避免滥用全局变量。
不透明性:单例模式会隐藏对象的创建和销毁细节,使得代码可读性较差。在阅读和理解代码时,可能需要查看单例模式的实现细节,才能明确对象的创建和销毁时机。
难以进行单元测试:由于单例模式创建的对象是全局唯一的,可能会对单元测试造成困扰。如果单例对象在测试期间具有副作用,可能需要考虑通过依赖注入等方式替代单例对象,以便更容易进行单元测试。
扩展困难:单例模式会限制类的实例化次数,可能会导致扩展困难。如果需要创建多个实例,或者在某些情况下需要动态改变实例的类型,可能需要重新设计代码结构或采用其他设计模式来满足需求。
单例模式在使用时需要注意线程安全性、全局变量的滥用、代码可读性、单元测试的困难以及扩展性。在实际应用中,需要根据具体的需求和场景来评估是否适合使用单例模式,并在实现时注意上述问题。
当使用单例模式时,还有一些额外的注意事项和技巧:
使用枚举实现单例:在Java中,可以使用枚举来实现单例模式。枚举类型的实例是通过静态代码块在类加载时创建的,且保证了线程安全性和序列化安全性。
public enum Singleton { INSTANCE; // 添加其他方法和属性 }
通过使用枚举实现单例,不仅可以避免线程安全和序列化等问题,而且可以简化代码。
容器管理单例:可以使用容器(如Spring框架)来管理单例对象,而不是自行创建和管理。容器可以确保单例对象的生命周期和依赖关系,提供方便的配置和管理。
避免反射攻击:使用反射机制可以绕过单例模式的限制直接创建新的实例。为了避免这种情况,可以在单例类的构造函数中添加判断,如果已经存在实例,则抛出异常。
private static boolean created = false; private Singleton() { synchronized (Singleton.class) { if (created) { throw new RuntimeException("Singleton instance already exists"); } created = true; } }
这样即使通过反射尝试创建新的实例,也能够在构造函数中捕获到异常。
- 使用依赖注入(DI):如果单例对象依赖其他对象,可以使用依赖注入来解决。通过将依赖对象作为参数传递给单例对象的构造函数或设置方法,实现解耦和可测试性。
public class Singleton { private Dependency dependency; private Singleton(Dependency dependency) { this.dependency = dependency; } // ... }
这种方式可以方便地替换依赖对象,更灵活地使用单例对象。
综上所述,使用枚举实现、使用容器管理、防止反射攻击和使用依赖注入等技巧可以进一步提升单例模式的安全性和灵活性。根据具体的应用场景和需求,选择合适的实现方式和技巧。
除了使用单例模式,还有其他一些类似的创建唯一实例的设计模式。这些模式主要包括:
Multiton模式:允许创建多个单例实例,每个实例都有唯一的标识符。Multiton模式可以通过实例持有者(例如Map)来管理实例,提供更细粒度的控制和管理。
Monostate模式(共享状态):与单例模式不同,Monostate模式不强制要求只有一个实例,而是让多个实例共享同一个状态。Monostate模式通过静态变量或共享存储来实现,每个实例都可以修改和访问该变量。
Prototype模式:允许通过复制或克隆来创建多个对象,每个对象都可以独立修改和使用。Prototype模式与单例模式不同之处在于,Prototype模式支持创建多个实例,而不要求实例具有唯一性。
这些模式与单例模式类似,但在某些方面具有不同的特点和优劣势。例如,Multiton模式可以创建多个实例,但管理和维护成本较高;Monostate模式可以分享状态,但可能会导致全局变量的滥用问题;Prototype模式可以创建多个对象,但需要考虑对象的复制和内存管理等问题。
根据具体的应用场景和需求,可以选择合适的创建唯一实例的设计模式。