作者:xiangzhihong 链接:https://juejin.cn/post/6948242055751532581
面向过程:面向过程性能比面向对象高。因为对象调用需要实例化,开销比较大,较消耗资源,所以当性能是最重要的考量因素的时候,比如单片机、嵌入式开发、Linux/Unix 等,一般采用面向过程开发。但是,面向过程没有面向对象易维护、易复用、易扩展。面向对象:面向对象易维护、易复用、易扩展。因为面向对象有封装、继承、多态性的特性,所以可设计出低耦合的系统,使得系统更加灵活、更加易于维护。
那为什么,面向过程性能比面向对象高呢?面向过程也需要分配内存,计算内存偏移量,Java 性能差的主要原因并不是因为它是面向对象语言,而是因为 Java 是半编译语言,最终的执行代码并不是可以直接被 CPU 执行的二进制机器码。而面向过程语言大多都是直接编译成机器码在电脑上执行,并且其它一些面向过程的脚本语言性能也并不一定比 Java 好。
当 .class 字节码文件通过 JVM 转为机器可以执行的二进制机器码时,JVM 类加载器首先加载字节码文件,然后通过解释器逐行进行解释执行,这种方式的执行速度相对比较慢。而且有些方法和代码块是反复被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成一次编译后,会将字节码对应的机器码保存下来,下次可以直接调用。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。
Java虚拟机所管理的内存包含程序计数器、Java虚拟机栈、本地方法栈、Java堆和方法区5个部分,模型图如下图所示。
由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储,这类内存区域为【线程私有】的内存。
程序计数器具有如下的特点:
Java虚拟机栈也是线程私有的,它的生命周期与线程的生命周期同步,虚拟机栈描述的是Java方法执行的线程内存模型。每个方法被执行的时候,Java虚拟机都会同步创建一个内存块,用于存储在该方法运行过程中的信息,每个方法被调用的过程都对应着一个栈帧在虚拟机中从入栈到出栈的过程。
Java虚拟机栈有如下的特点:
本地方法栈与虚拟机所发挥的作用很相似,区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则是为虚拟机使用到的本地方法服务。
Java堆是虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,java中“几乎”所有的对象实例都在这里分配内存。这里使用“几乎”是因为java语言的发展,及时编译的技术发展,逃逸分析技术的日渐强大,栈上分配、标量替换等优化手段,使java对象实例都分配在堆上变得不那么绝对。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法(G1之后开始变得不一样,引入了region,但是依旧采用了分代思想),Java堆中还可以细分为:新生代和老年代。再细致一点的有Eden空间、From Survivor空间、ToSurvivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,简写TLAB)。
OOM异常 Java堆的大小既可以固定也可以扩展,但是主流的虚拟机,堆的大小都是支持扩展的。如果需要线程请求分配内存,但堆已满且内存已无法再扩展时,就抛出 OutOfMemoryError 异常。比如:
/**
* VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOMTest {
public static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
List<Integer[]> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Integer[] ints = new Integer[2 * _1MB];
list.add(ints);
}
}
}
方法区和Java堆一样,是各个线程共享的内存区域,他用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在 HotSpot JVM 中,永久代(永久代实现方法区)中用于存放类和方法的元数据以及常量池,比如Class和Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,为此我们不得不对虚拟机做调优。
后来HotSpot放弃永久代(PermGen),jdk1.7版本中,HotSpot已经把原本放在永久代的字符串常量池、静态变量等移出,到了jdk1.8,完全废弃了永久代,方法区移至元空间(Metaspace)。比如类元信息、字段、静态属性、方法、常量等都移动到元空间区。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期间生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。方法区中存放:类信息、常量、静态变量、即时编译器编译后的代码。常量就存放在运行时常量池中。当类被 Java 虚拟机加载后, .class文件中的常量就存放在方法区的运行时常量池中。而且在运行期间,可以向常量池中添加新的常量。如String类的intern()方法就能在运行期间向常量池中添加字符串常量。
直接内存并不是虚拟机运行时数据区的组成部分,在 NIO 中引入了一种基于通道和缓冲的 IO 方式。它可以通过调用本地方法直接分配Java虚拟机之外的内存,然后通过一个存储在堆中的DirectByteBuffer对象直接操作该内存,而无须先将外部内存中的数据复制到堆中再进行操作,从而提高了数据操作的效率。
由于直接内存并非Java虚拟机的组成部分,因此直接内存的大小不受 Java 虚拟机控制,但既然是内存,如果内存不足时还是会抛出OutOfMemoryError异常。
下面是直接内存与堆内存的一些异同点:
服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。
Java的类加载器可以分为BootstrapClassLoader、ExtClassLoader和AppClassLoader,它们的作用如下。
类加载会涉及一些加载机制。
Java的内存管理主要涉及三个部分:堆 ( Java代码可及的 Java堆 和 JVM自身使用的方法区)、栈 ( 服务Java方法的虚拟机栈 和 服务Native方法的本地方法栈 ) 和 保证程序在多线程环境下能够连续执行的程序计数器。Java堆是进行垃圾回收的主要区域,故其也被称为GC堆;而方法区的垃圾回收主要针对的是新生代和中生代。总的来说,堆 (包括Java堆 和 方法区)是 垃圾回收的主要对象,特别是Java堆。
每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法虽然简单,但无法解决对象相互循环引用的问题。
从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。在Java中,GC Roots包括:
如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。标记复杂算法有两个主要的缺点:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。它的优点是每次只需要对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。而缺点也是显而易见的,内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法 分代收集算法,就是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
方法重写 在Java程序中,类的继承关系可以产生一个子类,子类继承父类,它具备了父类所有的特征,继承了父类所有的方法和变量。子类可以定义新的特征,当子类需要修改父类的一些方法进行扩展,增大功能,程序设计者常常把这样的一种操作方法称为重写,也叫称为覆写或覆盖。
方法重写有如下一些特点:
方法重载 方法重载是让类以统一的方式处理不同类型数据的一种手段。调用方法时通过传递给它们的不同个数和类型的参数来决定具体使用哪个方法,这就是多态性。所谓方法重载是指在一个类中,多个方法的方法名相同,但是参数列表不同。参数列表不同指的是参数个数、参数类型或者参数的顺序不同。
按值传递:值传递是指在调用函数时将实际参数复制一份传递到函数中,这样在函数中如果对参数进行修改,将不会影响到实际参数。简单来说就是直接复制了一份数据过去,因为是直接复制,所以这种方式在传递时如果数据量非常大的话,运行效率自然就变低了,所以Java在传递数据量很小的数据是值传递,比如Java中的各种基本类型:int、float、double、boolean等类型。
引用传递:引用传递其实就弥补了上面说的不足,如果每次传参数的时候都复制一份的话,如果这个参数占用的内存空间太大的话,运行效率会很底下,所以引用传递就是直接把内存地址传过去,也就是说引用传递时,操作的其实都是源数据,这样的话修改有时候会冲突,记得用逻辑弥补下就好了,具体的数据类型就比较多了,比如Object,二维数组,List,Map等除了基本类型的参数都是引用传递。
下面是使用hashCode()与equals()的相关规定:
为什么必须要重写 hashcode 方法?其实就是为了保证同一个对象,保证在 equals 相同的情况下 hashcode 值必定相同,如果重写了 equals 而未重写 hashcode 方法,可能就会出现两个没有关系的对象 equals 相同的(因为 equals 都是根据对象的特征进行重写的),但 hashcode 确实不相同的。
HashMap底层采用了数组+链表的数据结构,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
如果定位到的数组位置不含链表,那么执行查找、添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值initialCapacity默认为16,loadFactory默认为0.75。
public HashMap(int initialCapacity, float loadFactor) {
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30(230)
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
threshold = initialCapacity;
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现
}
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作,那什么是CAS。
CAS是compare and swap的缩写,中文称为【比较交换】。CAS是一种基于锁的操作,而且是乐观锁。在Java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
Java 按照锁的实现分为乐观锁和悲观锁,乐观锁和悲观锁并不是一种真实存在的锁,而是一种设计思想。
悲观锁 悲观锁是一种悲观思想,它总认为最坏的情况可能会出现,它认为数据很可能会被其他人所修改,所以悲观锁在持有数据的时候总会把资源 或者 数据 锁住,这样其他线程想要请求这个资源的时候就会阻塞,直到等到悲观锁把资源释放为止。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。悲观锁的实现往往依靠数据库本身的锁功能实现。
Java 中的 Synchronized 和 ReentrantLock 等独占锁(排他锁)也是一种悲观锁思想的实现,因为 Synchronzied 和 ReetrantLock 不管是否持有资源,它都会尝试去加锁,生怕自己心爱的宝贝被别人拿走。
乐观锁 乐观锁的思想与悲观锁的思想相反,它总认为资源和数据不会被别人所修改,所以读取不会上锁,但是乐观锁在进行写入操作的时候会判断当前数据是否被修改过(具体如何判断我们下面再说)。乐观锁的实现方案一般来说有两种:版本号机制 和 CAS实现 。乐观锁多适用于多度的应用类型,这样可以提高吞吐量。
在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
线程是进程中可独立执行的最小单位,也是 CPU 资源(时间片)分配的基本单位,同一个进程中的线程可以共享进程中的资源,如内存空间和文件句柄。线程有一些基本的属性,如id、name、以及priority。
Thread 中定义了下面三个优先级常量:
一个线程被创建后,会经历从创建到消亡的状态,下图是线程状态的变更过程。
线程同步和并发通常会问到Synchronized、volatile、Lock的作用。其中,Lock是一个类,而其余两个则是Java关键字。
Synchronized是Java的关键字,也是Java的内置特性,在JVM层面实现了对临界资源的同步互斥访问,通过对对象的头文件来操作,从而达到加锁和释放锁的目的。使用Synchronized修饰的代码或方法,通常有如下特性:
正是因为上面的特性,所以Synchronized的缺点也是显而易见的:即如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,因此效率很低。
volatile 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。并且volatile是禁止进行指令重排序。
所谓指令重排序,指的是处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
volatile为了保证原子性,必须具备以下条件:
按照作用的不同,Java的锁可以分为如下:
悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java 中,synchronized 关键字和 Lock 的实现类都是悲观锁。悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,Java 原子类中的递增操作就通过 CAS 自旋实现。乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
这里说到了CAS算法,那么什么是CAS算法呢?
一个线程失败或挂起并不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。而CAS就是一种非阻塞算法实现,也是一种乐观锁技术,它能在不使用锁的情况下实现多线程安全,因此是一种无锁算法。
CAS算法的定义:CAS的主要作用是不使用加锁就可以实现线程安全,CAS 算法又称为比较交换算法,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。CAS具体包括三个参数:当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。
原子更新的基本操作包括:
以AtomicInteger为例,代码如下:
public class AtomicInteger extends Number implements java.io.Serializable {
//返回当前的值
public final int get() {
return value;
}
//原子更新为新值并返回旧值
public final int getAndSet(int newValue) {
return unsafe.getAndSetInt(this, valueOffset, newValue);
}
//最终会设置成新值
public final void lazySet(int newValue) {
unsafe.putOrderedInt(this, valueOffset, newValue);
}
//如果输入的值等于预期值,则以原子方式更新为新值
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
//原子自增
public final int getAndIncrement() {
return unsafe.getAndAddInt(this, valueOffset, 1);
}
//原子方式将当前值与输入值相加并返回结果
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
}
再如,下面是使用多线程对一个int值进行自增操作的代码,如下所示。
public class AtomicIntegerDemo {
private static AtomicInteger atomicInteger = new AtomicInteger(0);
public static void main(String[] args){
for (int i = 0; i < 5; i++){
new Thread(new Runnable() {
public void run() {
//调用AtomicInteger的getAndIncement返回的是增加之前的值
System.out.println(atomicInteger.getAndIncrement());
}
}).start();
}
System.out.println(atomicInteger.get());
}
}
阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程【稍等一下】,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销,这就是自旋锁。
当前线程拥有其他线程需要的资源,当前线程等待其他线程已拥有的资源,都不放弃自己拥有的资源。
所谓反射,指的是在运行状态中,对于任意一个类,都能够获取这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性,而这种动态获取的信息以及动态调用对象的方法的功能就被称为Java语言的反射机制。
使用反射前需要事先获取到的字节码,在Java中,获取字节码的方式有三种:
Java 语言中的类、方法、变量、参数和包等都可以被标注。和 Javadoc 不同,Java 标注可以通过反射获取标注内容。根据作用时机的不同,Java的注解可以分为三种:
为了保证只有一个对象存在,可以使用单例模式,网上有,单例模式的七种写法。我们介绍一下常见的几种:
懒汉式 懒汉式使用的是static关键字,因此是线程不安全的。
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
如果要线程安全,那么需要使用synchronized关键字。
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
不过,使用synchronized锁住之后,运行效率明显降低。
静态内部类 静态内部类利用了classloder的机制来保证初始化instance时只有一个线程。
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
双重校验锁
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
双重检查锁定是synchronized的升级的写法,那为什么要使用volatile关键字呢,是为了禁止初始化实例时的重排序。我们知道,初始化一个实例在java字节码中会有4个步骤:
而后两步是有可能会重排序,而使用volatile可以禁止指令重排序。