标签:safe 原子操作 情况 tor 过程 依赖关系 ++ 锁对象 ogr
最近看到有不少粉丝私信我说,能不能给整理出一份面试的要点出来,说自己复习的时候思绪很乱,老是找不到重点。那么今天就先给大家分享一个面试几乎必问的点,并发!在面试中问的频率很高的一个是分布式,一个就是并发,具体干货都在下方了。
我:synchronized可以保证方法或者代码在运行时,同一时刻只有一个方法可以进入到临界区,同时还可以保证共享变量的内存可见性。
我:Java中每个对象都可以作为锁,这是synchronized实现同步的基础: 1、普通同步方法:锁是当前实例对象。
2、静态同步方法,锁是当前类的class对象。
3、同步代码块:锁是括号里的对象。
我:我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的java对象是天生的Monitor, 每一个java对象都有成为Monitor的潜质,因为在Java的设计中,每一个java对象自打娘胎出来就带了一把看不见的锁,它被叫做内部锁或者Monitor锁。
我:(接着说)Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象 都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中由一个Owner字段存放拥有该锁的线程的唯一标识, 表示该锁被这个线程占用。
public static void main(String [] args) { Vector<String> vector = new Vector<>(); for (int i=0; i<10; i++) { vector.add(i+""); } System.out.println(vector); }
public static void test() { List<String> list = new ArrayList<>(); for (int i=0; i<10; i++) { synchronized (Demo.class) { list.add(i + ""); } } System.out.println(list); }
我:轻量级锁提升程序同步性能的依据是:对于绝大部分的锁,在整个同步周期内是不存在竞争的(区别于偏向锁),这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥 量的开销,但如果存在竞争,除了互斥量的开销,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
我接着说:轻量级锁的加锁过程是: 1、在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word,这时候线程堆栈与对象头的状态如图:
轻量级锁的加锁过程
2、拷贝对象头中的Mark Word复制到锁记录(Lock Record)中。
3、拷贝成功后,虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向Lock Record的指针,并将线程栈帧中的Lock Record里的owner指针指向Object的Mark Word。如果这个更新 动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
4、如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明 多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志位的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
我:偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,那持有 偏向锁的线程将永远不需要同步。
我顿了下,接着说:当锁第一次被线程获取的时候,线程使用CAS操作把这个线程的ID记录在对象Mark Word中,同时置偏向标志位1.以后该线程在进入和退出代码块时不需要进行CAS操作 来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的ID。如果测试成功,表示线程已经获得了锁。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。 根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定状态。
我:偏向锁、轻量级锁都是乐观锁,重量级锁是悲观锁。一个对象刚开始实例化的时候,没有任何线程来访问它时,它是可偏向的,意味着它认为只可能有一个线程来访问它,所以当第一个线程 访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的Id,之后再访问这个对象只需要对比ID。一旦有第二个线程访问这个对象,因为偏向锁不会释放,所以第二个线程看到对象是偏向状态,表明在这个对象上存在竞争了,检查原来持有该对象的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是此时升级为轻量级锁)。如果不存在使用了,则可以将对象恢复成无锁状态,然后重新偏向。
我:(接着说)轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说自旋一下,另一个线程就会释放锁。但是当自旋超过一定次数,或者一个线程持有锁, 一个线程在自旋,又有第三个来访,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。简单的说就是:有竞争,偏向锁升级为轻量级锁,竞争逐渐激烈, 轻量级锁升级为重量级锁。
我:在JSR113标准中有有一段对JMM的简单介绍:Java虚拟机支持多线程执行。在Java中Thread类代表线程,创建一个线程的唯一方法就是创建一个Thread类的实例对象,当调用了对象的start方法后,相应的线程将会执行。线程的行为有时会与我们的直觉相左,特别是在线程没有正确同步的情况下。本规范描述了JMM平台上多线程程序的语义,具体包含一个线程对共享变量的写入何时能被其他线程看到。这是官方的接单介绍。
我:Java内存模型是内存模型在JVM中的体现。这个模型的主要目标是定义程序中各个共享变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量这类的底层细节。通过这些规则来规范对内存的读写操作,保证了并发场景下的可见性、原子性和有序性。 JMM规定了多有的变量都存储在主内存中,每条线程都有自己的工作内存,线程的工作内存保存了该线程中用到的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不是直接读写主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间 进行数据同步。而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。也就是说Java线程之间的通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
我:简单的说:Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如原子性、可见性和有序性的问题。JMM就是为了解决这些问题出现的, 这个模型建立了一些规范,可以保证在多核CPU多线程编程的环境下,对共享变量的读写的原子性、可见性和有序性。
我:不是的,它需要满足以下两个条件:
1、在单线程环境下不能改变程序运行的结果。
2、存在数据依赖关系的不允许重排序。
其实这两点可以归结为一点:无法通过happens-before原则推导出来的,JMM允许任意的排序。
我:这里有必要提到as-if-serial语义:所有的操作都可以为了优化而被重排序,但是你必须保证重排序后执行的结果不能被改变,编译器、runtime和处理器都必须遵守 as-if-serial语义。注意as-if-serial只保证单线程环境,多线程环境下无效。举个栗子:
int a=1; //A int b=2; //B int c=a+b; //C
A,B,C三个操作存在如下关系:A和B不存在数据依赖,A和C,B和C存在数据依赖,因此在重排序的时候:A和B可以随意排序,但是必须位于C的前面,但无论何种顺序,最终结果C都是3.
public class RecordExample2 { int a = 0; boolean flag = false; /** * A线程执行 */ public void writer(){ a = 1; // 1 flag = true; // 2 } /** * B线程执行 */ public void read(){ if(flag){ // 3 int i = a + a; // 4 } }}
假如操作1和操作2之间重排序,可能会变成下面这种执行顺序:
1、线程A执行flag=true;
2、线程B执行if(flag);
3、线程B执行int i = a+a;
4、线程A执行a=1。
按照这种执行顺序线程B肯定读不到线程A设置的a值,在这里多线程的语义就已经被重排序破坏了。操作3和操作4之间也可以重排序,这里就不阐述了。但是他们之间存在一个控制依赖的关系,因为只有操作3成立操作4才会执行。当代码中存在控制依赖性时,会影响指令序列的执行的并行度,所以编译器和处理器会采用猜测执行来克服控制依赖对并行度的影响。假如操作3和操作4重排序了,操作4先执行,则先会把计算结果临时保存到重排序缓冲中,当操作3为真时才会将计算结果写入变量i中。
1、可见性:可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程的修改的结果,另一个线程能够马上看到。比如:用volatile修饰的变量,就会具有可见性,volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存,所以对其他线程是可见的。但这里要注意一个问题,volatile只能让被他修饰的内容具有可见性,不能保证它具有原子性。比如 volatile int a=0; ++a;这个变量a具有可见性,但是a++是一个非原子操作,也就是这个操作同样存在线程安全问题。在Java中,volatile/synchronized/final实现了可见性。
2、原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么都不执行。原子就像数据库里的事务一样,他们是一个团队,同生共死。看下面一个简单的栗子:
i=0; //1 j=i; //2 i++; //3 i=j+1; //4
上面的四个操作,只有1是原子操作,其他都不是原子操作。比如2包含了两个操作:读取i,将i值赋给j。在Java中synchronized/lock操作中保证原子性。
3、有序性:程序执行的顺序按照代码的先后顺序执行。 前面JMM中提到了重排序,在java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,而且重排序不会影响单线程的运行结果,但是对多线程有影响。Java中提供了volatile和synchronized保证有序性。
那么volatile的内存语义是如何实现的呢?对于一般的变量会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。
volatile的重排序规则:
1、如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前。
2、当第二个操作为volatile写,则不管第一个操作是啥,都不能重排序。这个操作确保了volatile写之前的操作不会被编译器重排序到volatile写之后。
3、当第一个操作为volatile写,第二个操作为volatile读,不能重排序。
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以JMM采用了保守策略。 如下:
1、在每一个volatile写操作前插入一个StoreStore屏障。
2、在每一个volatile写操作后插入一个StoreLoad屏障。
3、在每一个volatile读操作后插入一个LoadLoad屏障。
4、在每一个volatile读操作后插入一个LoadStore屏障。
总结:StoreStore屏障->写操作->StoreLoad屏障->读操作->LoadLoad屏障->LoadStore屏障。 下面通过一个例子简单分析下: volatile原理分析
if (this.value == A) { this.value = B return true; } else { return false; }
private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } private volatile int value;
如上是AtomicInteger的源码: 1、Unsafe是CAS的核心类,Java无法直接访问底层操作系统,而是通过本地native方法访问。不过尽管如此,JVM还是开了个后门:Unsafe,它提供了 硬件级别的原子操作。
2、valueOffset:为变量值在内存中的偏移地址,Unsafe就是通过偏移地址来得到数据的原值的。
3、value:当前值,使用volatile修饰,保证多线程环境下看见的是同一个。
// AtomicInteger.java public final int addAndGet(int delta) { return unsafe.getAndAddInt(this, valueOffset, delta) + delta; } // Unsafe.java public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
在方法compareAndSwapInt(var1, var2, var5, var5 + var4)中,有四个参数,分别代表:对象,对象的地址,预期值,修改值。
其实在面试里,多线程,并发这块问的还是非常频繁的,大家看完之后有什么不懂的欢迎在评论区讨论,也可以私信问我,一般我看到之后都会回的!
面试必看!花了三天整理出来的并发编程的锁及内存模型,看完你就明白了!
标签:safe 原子操作 情况 tor 过程 依赖关系 ++ 锁对象 ogr
原文地址:https://www.cnblogs.com/lwh1019/p/13172317.html