虚拟机内存模型与高效并发丶一个站在Java后端设计之路的男青(2)

电脑杂谈  发布时间:2019-06-30 12:12:23  来源:网络整理

在Window系统和Linux系统上,Java线程的实现是基于的线程模型,所谓的模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为的线程模型。

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

Java中可以使用三种方式来保障程序的线程安全:1).互斥同步;2).非阻塞同步;3).无同步。

在Java中最基本的使用同步方式是使用sychronized关键字,该关键字在被编译之后会在同步代码块前后形成monitorenter和monitorexit字节码指令。这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果在Java程序中明确指定了对象参数,就会使用该对象,否则就会根据sychronized修饰的是实例方法还是类方法,去去对象实例或者Class对象作为加锁对象。

主存和内存_主存和内存的区别_主存和内存的区别

synchronized先天具有重入性:根据虚拟机的要求,在执行sychronized指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了该对象的锁,就把锁的计数器加1,相应地执行monitorexit指令时会将锁的计数器减1,当计数器为0时就释放锁。弱获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

除了使用sychronized,我们还可以使用JUC中的ReentrantLock来实现同步,它与sychronized类似,区别主要表现在以下3个方面:

等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待;

公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁无法保证,当锁被释放时任何在等待的线程都可以获得锁。sychronized本身时非公平锁,而ReentrantLock默认是非公平的,可以通过构造函数要求其为公平的。

这样的话就可以使用contructor创建新的对象,用get()和set()方法获取和修改类中与field对象关联的字段,用invoke()方法调用与method对象关联的方法。当na对象里有个对指向一个sta对象的指针的调用而调用线程不是那个sta对象关联的线程时,调用将会转成向被调用的sta对象的关联线程发送消息,此时照样会发生线程切换。 一个lock对象上可以绑定多个condition对象,这样实现了本方线程只唤醒对方线程,而jdk1.5之前,一个同步只能有一个锁,不同的同步只能用锁来区分,且锁嵌套时容易死锁。

在JDK1.5之前,sychronized在多线程环境下比ReentrantLock要差一些,但是在JDK1.6以上,虚拟机对sychronized的性能进行了优化,性能不再是使用ReentrantLock替代sychronized的主要因素。

(1)乐观锁:就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则jvm会把该线程放入锁池当中。notify线程会在执行完同步代码之后通知在阻塞队列中的线程,也就是说notify的那个线程并不是立即释放锁,而是在同步方法执行完,释放锁以后,wait方法的那个线程才会继续执行。

这里要注意上面的CAS操作是分3个步骤的,但是这3个步骤必须一次性完成,因为不然的话,当判断“内存中的值”与“旧值”相等之后,向内存写入“新值”之间被其他线程修改就可能会得到错误的结果。JDK中的sun.misc.Unsafe中的compareAndSwapInt等一系列方法Native就是用来完成这种操作的。另外还要注意,上面的CAS操作存在一些问题:

AtomicReference

所谓无同步方案就是不需要同步,比如一些集合属于不可变集合,那么就没有必要对其进行同步。有一些方法,它的作用就是一个函数,这在函数式编程思想里面比较常见,这种函数通过输入就可以预知输出,而且参与计算的变量都是局部变量等,所以也没必要进行同步。还有一种就是线程局部变量,比如ThreadLocal等。

为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。既然synchronized里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在synchronized的边界做忙循环,这就是自旋。当多个线程访问mythread的run方法时,以排队的方式进行处理(这里排队是按照cpu分配的先后顺序而定的),一个线程想要执行synchronized修饰的方法里的代码,首先是尝试获得锁,如果拿到锁,执行synchronized代码块中的内容:拿不到锁,这个线程就会不断尝试获得这把锁,知道拿到位置,而且是多个线程同时去竞争这把锁。

1)开启线程需要占用一定的内存空间(默认情况下,主线程占用1m,子线程占用512kb),如果开启大量的线程,会占用大量的内存空间,降低程序的性能。开启线程需要占用一定的内存空间(默认情况下,主线程占用1m,子线程占用512kb),如果开启大量的线程,会占用大量的内存空间,降低程序的性能。每开一个线程都会造成系统额外的负担,开启线程需要占用一定的内存空间(默认情况下,每一条线程都会占用 512kb),如果开启大量的线程,会占用大量的内存空间,降低程序的性能。

我们可以通过参数-XX:PreBlockSpin来指定自旋的次数,默认值是10次。在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间, 比如100个循环。另一方面,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

下面是自旋锁的一种实现的例子:

public class SpinLock {
    private AtomicReference<Thread> sign = new AtomicReference<>();
    public void lock() {
        Thread current = Thread.currentThread();
        while(!sign.compareAndSet(null, current)) ;
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        sign.compareAndSet(current, null);
    }
}
复制代码

主存和内存_主存和内存的区别_主存和内存的区别

从上面的例子我们可以看出,自旋锁是通过CAS操作,通过比较期值是否符合预期来加锁和释放锁的。在lock方法中如果sign中的值是null,也就代标锁被释放了,否则锁被其他线程占用,需要通过循环来等待。在unlock方法中,通过将sign中的值设置为null来通知正在等待的线程锁已经被释放。

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。

public class StringBufferTest {
    StringBuffer sb = new StringBuffer();
    public void append(){
        sb.append("a");
        sb.append("b");
        sb.append("c");
    }
}
复制代码

这里每次调用sb.append()方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append()方法时进行加锁,最后一次append()方法结束后进行解锁。

因而比较好的模式是单个线程收,然后再将数据分发到后台不同的处理线程,然而在接收线程和处理线程之间的的数据传递可能需要采用锁的方式,这样就导致程序性能下降(然而在采取分主的情境下,即单生产者和单消费者,可以采用无锁队列的方式来实现,这样就可以大幅提高程序性能),如果某种形式的锁,为了避免频繁的加锁解锁操作,比较好的改进是数据的批传递,这样可以数百倍的减少加解锁的调用,也可以大幅减少线程的上下文切换开销。轻量级锁并不是用来代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。对于单线程下,我们不可避免程序中出现io操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到io阻塞时就切换到另外一个任务去计算,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的io操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,io比较少,从而更多的将cpu的执行权限分配给我们的线程。

第一部分:用于存储对象自身的运行时数据,如哈希码、gc分代年龄、锁状态标志、线程持有的锁等,官方称之为“mark word”。java任意类型的对象都有一个标志位(或者对象锁),该标志位具有0,1两种状态,其开始状态为1,当某个线程执行了“synchronized(object)”语句后,object对象(也叫做同步监视器)的标志位(或者对象锁)变为0的状态,直到执行完整个synchronized语句中的代码块后主存和内存的区别,该object对象的标志位(或者对象锁)又回到了1状态。例如在32位的hotspot虚拟机 中对象未被锁定的状态下,mark word的32个bits空间中的25bits用于存储对象哈希码(hashcode),4bits用于存储对象分代年龄,2bits用于存储锁标志 位,1bit固定为0,在其他状态(轻量级锁定、重量级锁定、gc标记、可偏向)下对象的存储内容如下表所示。

hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈希码(hashcode)、gc分代年龄、锁状态标志、线程持有的锁、偏向线程id、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个bits,官方称它为“mark word”。如果为0状态,则表明已经有其他的线程(假设为线程x)正在执“synchronized()”,那么这个线程a将会暂时阻塞,让出cpu资源,进入到线程池中去等待,直到另外的线程x执行完相关的同步代码,线程x并将object对象(也叫做同步监视器)的标志位变为1状态,此时,线程a的阻塞就会被取消,线程a继续运行,该线程会将object对象(或同步监视器)的标志位(或者对象锁)变为0状态,防止其他的线程再次进入到相关的同步代码块中。调用object.wait()时,线程先要获取这个对象的对象锁,当前线程必须在锁对象保持同步,把当前线程添加到等待队列中,随后另一线程可以同步同一个对象锁来调用object.notify(),这样将唤醒原来等待中的线程,然后释放该锁。


本文来自电脑杂谈,转载请注明本文网址:
https://www.pc-fly.com/a/jisuanjixue/article-109490-2.html

相关阅读
    发表评论  请自觉遵守互联网相关的政策法规,严禁发布、暴力、反动的言论

    热点图片
    拼命载入中...