jvmjvm创建对象的过程锁如果多级部署怎么办

1. 锁优化的思路和方法

在[高并发Java 一] 前言中有提到并发的级别。

一旦用到锁,就说明这是阻塞式的,所以在并发度上一般来说都会比无锁的情况低一点。

这里提到的锁优化,是指在阻塞式的情况下,如何让性能不要变得太差。但是再怎么优化,一般来说性能都会比无锁的情况差一点。

这里要注意的是,在[高并发Java 五] JDK并发包1中提到的ReentrantLock中的tryLock,偏向于一种无锁的方式,因为在tryLock判断时,并不会把自己挂起。

锁优化的思路和方法总结一下,有以下几种。

1.1 减少锁持有时间

像上述代码这样,在进入方法前就要得到锁,其他线程就要在外面等待。
这里优化的一点在于,要减少其他线程等待的时间,所以,只用在有线程安全要求的程序上加锁

将大对象(这个对象可能会被很多线程访问),拆成小对象,大大增加并行度,降低锁竞争。降低了锁的竞争,偏向锁,轻量级锁成功率才会提高。

最最典型的减小锁粒度的案例就是ConcurrentHashMap。这个在[高并发Java 五] JDK并发包1有提到。

最常见的锁分离就是读写锁ReadWriteLock,根据功能进行分离成读锁和写锁,这样读读不互斥,读写互斥,写写互斥,即保证了线程安全,又提高了性能,具体也请查看[高并发Java 五] JDK并发包1。

读写分离思想可以延伸,只要操作互不影响,锁就可以分离。

从头部取出,从尾部放数据。当然也类似于[高并发Java 六] JDK并发包2中提到的ForkJoinPool中的工作窃取。

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化 。

这种情况,根据锁粗化的思想,应该合并

当然这是有前提的,前提就是中间的那些不需要同步的工作是很快执行完成的。


 
在一个循环内不同得获得锁。虽然JDK内部会对这个代码做些优化,但是还不如直接写成


当然如果有需求说,这样的循环太久,需要给其他线程不要等待太久,那只能写成上面那种。如果没有这样类似的需求,还是直接写成下面那种比较好。
1.5 锁消除


锁消除是在编译器级别的事情。


在即时编译器时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作。


也许你会觉得奇怪,既然有些对象不可能被多线程访问,那为什么要加锁呢?写代码时直接不加锁不就好了。


但是有时,这些锁并不是程序员所写的,有的是JDK实现中就有锁的,比如Vector和StringBuffer这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。





上述代码中的StringBuffer.append是一个同步操作,但是StringBuffer却是一个局部变量,并且方法也并没有把StringBuffer返回,所以不可能会有多线程去访问它。
那么此时StringBuffer中的同步操作就是没有意义的。


开启锁消除是在JVM参数上设置的,当然需要在server模式下:


并且要开启逃逸分析。 逃逸分析的作用呢,就是看看变量是否有可能逃出作用域的范围。
比如上述的StringBuffer,上述代码中craeteStringBuffer的返回是一个String,所以这个局部变量StringBuffer在其他地方都不会被使用。如果将craeteStringBuffer改成


 
那么这个 StringBuffer被返回后,是有可能被任何其他地方所使用的(譬如被主函数将返回结果put进map啊等等)。那么JVM的逃逸分析可以分析出,这个局部变量 StringBuffer逃出了它的作用域。
所以基于逃逸分析,JVM可以判断,如果这个局部变量StringBuffer并没有逃出它的作用域,那么可以确定这个StringBuffer并不会被多线程所访问,那么就可以把这些多余的锁给去掉来提高性能。










 

显然,锁消除的效果还是很明显的。
2. 虚拟机内的锁优化
首先要介绍下对象头,在JVM中,每个对象都有一个对象头。
Mark Word,对象头的标记,32位(32位系统)。
描述对象的hash、锁信息,垃圾回收标记,年龄
还会保存指向锁记录的指针,指向monitor的指针,偏向锁线程ID等。
简单来说,对象头就是要保存一些系统性的信息。

所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程 。
大部分情况是没有竞争的(某个同步块大多数情况都不会出现多线程同时竞争锁),所以可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时,会判断是否偏向锁指向我,那么该线程将不用再次获得锁,直接就可以进入同步块。
偏向锁的实施就是将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
当其他线程请求相同的锁时,偏向模式结束

在竞争激烈的场合,偏向锁会增加系统负担(每次都要加一次是否偏向的判断)

Vector是一个线程安全的类,内部使用了锁机制。每次add都会进行锁请求。上述代码只有main一个线程再反复add请求锁。
使用如下的JVM参数来设置偏向锁:

 
BiasedLockingStartupDelay表示系统启动几秒钟后启用偏向锁。默认为4秒,原因在于,系统刚启动时,一般数据竞争是比较激烈的,此时启用偏向锁会降低性能。
由于这里为了测试偏向锁的性能,所以把延迟偏向锁的时间设置为0。














Java的多线程安全是基于Lock机制实现的,而Lock的性能往往不如人意。





互斥是一种会导致线程挂起,并在较短的时间内又需要重新调度回原线程的,较为消耗资源的操作。


为了优化Java的Lock机制,从Java6开始引入了轻量级锁的概念。


轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥的几率,并不是要替代互斥。





如果偏向锁失败,那么系统会进行轻量级锁的操作。它存在的目的是尽可能不用动用操作系统层面的互斥,因为那个性能会比较差。因为JVM本身就是一个应用,所以希望在应用层面上就解决线程同步问题。


总结一下就是轻量级锁是一种快速的锁定方法,在进入互斥之前,使用CAS操作来尝试加锁,尽量不要用操作系统层面的互斥,提高了性能。


那么当偏向锁失败时,轻量级锁的步骤:


1.将对象头的Mark指针保存到锁对象中(这里的对象指的就是锁住的对象,比如synchronized (this){},this就是这里的对象)。


2.将对象头设置为指向锁的指针(在线程栈空间中)。


lock位于线程栈中。所以判断一个线程是否持有这把锁,只要判断这个对象头指向的空间是否在这个线程栈的地址空间当中。
如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁),就是操作系统层面的同步方法。在没有锁竞争的情况,轻量级锁减少传统锁使用OS互斥量产生的性能损耗。在竞争非常激烈时(轻量级锁总是失败),轻量级锁会多做很多额外操作,导致性能下降。





当竞争存在时,因为轻量级锁尝试失败,之后有可能会直接升级成重量级锁动用操作系统层面的互斥。也有可能再尝试一下自旋锁。


如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作(自旋),并且不停地尝试拿到这个锁(类似tryLock),当然循环的次数是有限制的,当循环次数达到以后,仍然升级成重量级锁。所以在每个线程对于锁的持有时间很少时,自旋锁能够尽量避免线程在OS层被挂起。





JDK1.7中,去掉此参数,改为内置实现


如果同步块很长,自旋失败,会降低系统性能。如果同步块很短,自旋成功,节省线程挂起切换时间,提升系统性能。


2.4 偏向锁,轻量级锁,自旋锁总结


上述的锁不是Java语言层面的锁优化方法,是内置在JVM当中的。


首先偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗,如果仍然是同个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁。


而轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作。


为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。


可见偏向锁,轻量级锁,自旋锁都是乐观锁。


3. 一个错误使用锁的案例


一个很初级的错误在于,在 [高并发Java 七] 并发设计模式提到,Interger是final不变的,每次++后,会产生一个新的 Interger再赋给i,所以两个线程争夺的锁是不同的。所以并不是线程安全的。





这里来提ThreadLocal可能有点不合适,但是ThreadLocal是可以把锁代替的方式。所以还是有必要提一下。


基本的思想就是,在一个多线程当中需要把有数据冲突的数据加锁,使用ThreadLocal的话,为每一个线程都提供一个对象实例。不同的线程只访问自己的对象,而不访问其他的对象。这样锁就没有必要存在了。


由于SimpleDateFormat并不线程安全的,所以上述代码是错误的使用。最简单的方式就是,自己定义一个类去用synchronized包装(类似于Collections.synchronizedMap)。这样做在高并发时会有问题,对 synchronized的争用导致每一次只能进去一个线程,并发量很低。
这里使用ThreadLocal去封装SimpleDateFormat就解决了这个问题


每个线程在运行时,会判断是否当前线程有SimpleDateFormat对象

















首先Thread类中有一个成员变量:








ThreadLocalMap中发生hash冲突时,不是像HashMap这样用链表来解决冲突,而是是将索引++,放到下一个索引处来解决冲突。

  1. 触发 : 程序创建对象,例如clone,反序列化,new等。

  2. 验证类加载 : 当虚拟机接收到new指令时,检查指令的参数能否在常量池定位到一个类的符号引用,并且检查此符号引用的类是否已经被加载、解析、初始化过,如果没有,则先执行对应的初始化过程。

  3. 分配内存空间 : 为新生代对象分配内存,所需内存在类加载完成后便可完全确定。分配内存空间即从堆中划分一块确定大小的内存,此时分两种情况:

    • ①堆内存规整,使用中内存与空闲内存被一个指针隔离在两边,此时只需要将指针向空闲空间方向挪动此对象大小距离即可,这种方式称为指针碰撞;

    • ②如果内存不规整,使用中内存与空闲内存相互交错,此时虚拟机需要维护一个列表,记录哪些内存块可用,在分配空间时需要找到一块足够大的空间划分给对象实例,并更新维护的列表,这种方式为空闲列表。

           由于对象创建对象非常的频繁,在并发情况下很多操作都不是线程安全的,例如修改一个指针所指向的位置,可能为A分配内容时还没来得及修改指针,对象B又引用了此指针位置为碰撞点来划分内存,两种解决方案:

    • ①对内存分配过程同步处理-实际虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
    • 来查询),哪个线程需要分配内存,就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定,可以通过-XX:+/-useTLAB来开启关闭
  4. 零值初始化 : 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包含对象头),这一步操作保证了对象的实例字段在java代码中可以不赋值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

  5. 对对应对象头进行设置必要设置,例如这个对象属于哪个类,如果找到类的元数据,对象的哈希码,对象的gc分代年龄等信息,根据虚拟机的运行状态,如果是否启用偏向锁(主要为了解决无锁的性能问题,现在锁基本都是可重入锁,在A线程获得锁后,会被标识为偏向锁,简单说就是有个标记,这个线程已经获取这个锁了,在锁的过程中再竞争锁无需进行cas等操作,不会延迟本地调用,在释放偏向锁的会有一定的性能损耗,但对比偏向锁带来的提升,总体性能还是有提升的)等,对象头会有不同的设置方式。

  6. 调用init方法 : 在上面工作完成后,从虚拟机的角度新对象已经产生了,但从java程序角度来说,对象创建才刚刚开始,在new指令后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个对象就真正的产生了。

  1. 对象头:对象头包含两部分信息,第一部分存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等、这部分数据的长度在32位合64位的虚拟机(未开启压缩指针)中分别为32bit和64位,官方称它为 “Mark Word”。对象状态在无锁、可偏向、轻量级锁定、重量级锁定、GC标记、状态下Mark Word存储内容有所差别。
  • A线程获得锁,会在A线程的栈帧中变量和锁对象的对象头中存储该线程ID,当该线程再次尝试获取该对象锁时,不需要cas操作,只需要判断是否是当前线程,当栈帧出栈完成,A线程并不会释放锁,需要等到B线程竞争该锁才释放,(偏向锁的释放需要等到全局安全点,即在此时间点没有字节码执行),可以通过-XX:-UseBiasedLocking=false关闭偏向锁,则默认会进入轻量级锁。

  • record,这就是获取了锁。B线程在锁竞争时,发现锁已经被A线程占用,则B线程不进入内核态,让B线程自旋,执行空循环,等待A线程释放锁。如果完成自旋策略还是发现A线程没有释放锁,或者让C线程占用了,则B线程试图将轻量级锁升级为重量级锁。

  • 重量级锁 : 让争抢锁的线程从用户态转换成内核态,使cpu借助操作系统进行线程协调。

  1. 实例数据:存储真正的有效信息,即代码中定义的字段内容,无论是从父类中继承下来的,还是再子类中定义的,都需要记录下来。HotSpot虚拟机默认的分配策略为longs/doubles,ints,shorts/chars,bytes/booleans,oops,相同宽度的字段总是被分配到一起。再满足这个前提条件下,再父类中定义的变量会出现在子类之前。

  2. 对齐填充:不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。由于HotSpot的自动内存管理系统要求对象起始地址必须是8字节的倍数,而对象头部分正好时8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

建立对象即为了使用对象,我们的Java程序需要根据栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所有具体的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种。

  • 句柄 : 从Java堆中划分出一块内存来作为句柄池,栈帧中reference储存句柄的地址,而句柄中包含了对象实例数据和类型数据各自的地址信息 。
  • 直接指针 : 栈帧中reference储存对象地址,堆对象需要考虑如何访问类型数据的相关信息。

优缺点:使用句柄时,在对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中的实例数据指针,而reference本身不需要修改。只用直接指针速度更快,它节省了一次指针定位的时间开销(reference->对象相对于句柄的reference->句柄->对象),由于对象的访问非常频繁,因此这类开销积少成多也是一项客观的执行成本成本。HotSpot使用直接指针来实现对象访问。

参考文献: 周志明.深入理解Java虚拟机[M].第2版.北京:机械工业出版社

我要回帖

更多关于 jvm创建对象的过程 的文章

 

随机推荐