首页 > 编程语言 >【并发编程】(二)锁与并发

【并发编程】(二)锁与并发

时间:2023-12-12 17:23:19浏览次数:47  
标签:synchronized 对象 编程 并发 ThreadLocal 线程 jvm class

并发编程是编程中重要的一环,在特定的场景下,熟悉并发知识并且掌握并发编程显得尤为重要
在本篇开篇前针对几个知识点进行说明,虽然有些组件不是位于juc下并且它本身是无锁实现的,但是它却能解决并发相关的问题
  • ThreadLocal的原理

  ThreadLocal应该是java工程师很熟悉的一个组件,它在本地线程、连接池中都有它的身影。它的目的是为了保证多个线程访问变量时的安全访问。通常情况下,我们将变量放入ThreadLocal中,ThreadLocal会为每个变量创建一个“独立的”值,这样避免了多个线程访问相同变量产生的并发问题。所以ThreadLocal变量也称为线程本地变量(私有的)。

  

  对于ThreadLocal来说,我们看它的模型很难不想到Map的k-v模型,其实在早期的ThreadLocal中,它的设计就是类似Map的设计,通过当前线程进行操作并获取对应线程的值,不多说我们看源码

    首先我们可以看到,在ThreadLocal中,为我们定义了几个基础的操作,如get、set、remove这些方法的作用不需要多说,它们符合一个容器最基本的操作,增删查,不同的是除此之外还提供了一个无参构造函数和一个方法withInitial,在我们常规的使用中,例如我们通过get获取当前线程中的本地变量,如果ThreadLocal中没有对应的值,则set一个默认值...这种方式相对较为繁琐,如果我希望在没有默认值时调用一个默认值返回使用,可以使用withInitial,例如:
  ThreadLocal<Integer> integerThreadLocal = ThreadLocal.withInitial(() -> Integer.MAX_VALUE); 因为在withInitial中参数使用了Supplier,我们可以通过lambda表达式创建一个默认的返回值

 

 

  在ThreadLocal中的源码中,我们可以发现,它利用的就是上篇中Thread模型中的线程模型,详情看源码:

 1 public T get() {
 2         Thread t = Thread.currentThread(); //Thread模型 获取当前线程模型
 3         ThreadLocalMap map = getMap(t);
 4         if (map != null) {
 5             ThreadLocalMap.Entry e = map.getEntry(this);
 6             if (e != null) {
 7                 @SuppressWarnings("unchecked")
 8                 T result = (T)e.value;
 9                 return result;
10             }
11         }
12         return setInitialValue();
13 }
14 
15 ThreadLocalMap getMap(Thread t) {
16         return t.threadLocals;
17 }
18 ThreadLocal.ThreadLocalMap threadLocals

  首先,对于ThreadLocal而言,线程的区分其实就是当前Thread模型获取的 Thread.currentThread() 当前线程,而ThreadLocalMap是ThreadLocal中的一个内部实例,它的作用就是存储这些Thread的k-v数据,如果获取失败或者获取为空就使用 initialValue()获取默认值,默认值就是通过 withInitial中的 Supplier进行设置的。

1 public void set(T value) {
2         Thread t = Thread.currentThread();
3         ThreadLocalMap map = getMap(t);
4         if (map != null) {
5             map.set(this, value);
6         } else {
7             createMap(t, value);
8         }
9     }

  而对于set方法,就更简单明了。获取当先线程,并且去成员变量ThreadLocalMap中查找,如果这时还没有创建Map就先去创建,remove也是一样的,这里不做过多解释,那么我们得出结论就是ThreadLocalMap其实就是ThreadLocal的核心。

 1  static class ThreadLocalMap {
 2 
 3         /**
 4          * The entries in this hash map extend WeakReference, using
 5          * its main ref field as the key (which is always a
 6          * ThreadLocal object).  Note that null keys (i.e. entry.get()
 7          * == null) mean that the key is no longer referenced, so the
 8          * entry can be expunged from table.  Such entries are referred to
 9          * as "stale entries" in the code that follows.
10          */
11         static class Entry extends WeakReference<ThreadLocal<?>> {
12             /** The value associated with this ThreadLocal. */
13             Object value;
14 
15             Entry(ThreadLocal<?> k, Object v) {
16                 super(k);
17                 value = v;
18             }
19         }
20 
21         /**
22          * The initial capacity -- MUST be a power of two.
23          */
24         private static final int INITIAL_CAPACITY = 16;
25 
26         /**
27          * The table, resized as necessary.
28          * table.length MUST always be a power of two.
29          */
30         private Entry[] table;
31 
32         /**
33          * The number of entries in the table.
34          */
35         private int size = 0;
36 
37         /**
38          * The next size value at which to resize.
39          */
40         private int threshold; // Default to 0
41 
42         /**
43          * Set the resize threshold to maintain at worst a 2/3 load factor.
44          */
45         private void setThreshold(int len) {
46             threshold = len * 2 / 3;
47         }
48 
49         /**
50          * Increment i modulo len.
51          */
52         private static int nextIndex(int i, int len) {
53             return ((i + 1 < len) ? i + 1 : 0);
54         }
55 
56         /**
57          * Decrement i modulo len.
58          */
59         private static int prevIndex(int i, int len) {
60             return ((i - 1 >= 0) ? i - 1 : len - 1);
61         }
62 
63         /**
64          * Construct a new map initially containing (firstKey, firstValue).
65          * ThreadLocalMaps are constructed lazily, so we only create
66          * one when we have at least one entry to put in it.
67          */
68         ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
69             table = new Entry[INITIAL_CAPACITY];
70             int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
71             table[i] = new Entry(firstKey, firstValue);
72             size = 1;
73             setThreshold(INITIAL_CAPACITY);
74         }
75 }

  在ThreadLocalMap源码中,我们通过它的成员变量和它的结构,不难看出它的模型很像我们之前解析HashMap的源码,HashMap源码分析请查看之前的文章《HashMap源码分析》,源码版本为1.8,其他版本源码不做过多介绍。它是将Key对应的Value包装成内部类Entry,与HashMap类似,它们的初始容量都为16,扩容因子0.75,它与HashMap相同都存在扩容,但是与HashMap不同的是,它对于Key的值是以Entry存在,Entry又是Map的k-v模型,我们知道HashMap底层由数组+链表+红黑树,在达到扩容因子后会进行扩容,当链表的长度达到阈值后会进行tree树化,将链表旋转为红黑树结构,这样的好处是控制的树的长度和宽度,使查找更为高效;但是扩容的代价很大,在ThreadLocalMap中扩容同样性能较低,在线程数量多且本地变量少的情况下,就需要进一步改进,在1.8中每一个线程Thread所拥有一个Map实例,这个Map就是ThreadLocalMap,ThreadLocal为key,而本地变量的值为value。这样做的好处是:每个ThreadLocalMap中存储的k-v数量会更少,这样可以避免大量的扩容消耗;当某个Thread获取本地变量后,会将ThreadLocal作为key从ThreadLocalMap中获取对应的value,其他线程无法访问自己的ThreadLocalMap实例、自己也无法访问其他线程的ThreadLocalMap,从而达到线程隔离的目的。所以在1.8的模型应该为:

  •  弱引用

  在Entry中,我们可以看到Entry被包装了 WeakReference,从字面意思可以看出这是一个弱引用。上面的注释中提到:The entries in this hash map extend WeakReference, using its main ref field as the key (which is always a ThreadLocal object). Note that null keys (i.e. entry.get() == null) mean that the key is no longer referenced, so the entry can be expunged from table. Such entries are referred to as "stale entries" in the code that follows. entries包含了一个继承WeakReference的map,当key为null时意味着它不在被引用,可以从列表中删除(这里的删除指的是GC回收),这段注释猛然看起来很难以理解,那么为什么它们不直接引用ThreadLocal作为key,而是使用WeakReference呢?

  首先我们明确一个概念,在前文中说到jvm的守护线程gc线程,会通过gc的回收将已经失去引用的资源回收掉。在线程池篇幅中我们说不推荐使用Executors的工厂方法创建线程池,有很大的原因是因为在BlockQueue队列中是不限制大小的,如果我们添加大量的任务,会导致这些任务无法被完成且这些任务保持着强引用,gc没有办法回收,在超过了jvm的伊甸区并且存活时间够长之后会进入老年代,old。如果gc无法回收,导致堆栈内存占用过大直到无法为新对象开辟新的内存时,会导致OOM。那么什么叫内存泄漏呢?它是指不再使用的内存但是没有归还给jvm,它们都是因为引用导致无法被回收。而在jvm中,目前的垃圾回收器都是以根可达算法,它会认为强引用的对象是不需要被回收的

  那么老规矩,我们用一个图来描述一下WeakReference:

  

  例如我们有一个代码块function(),一个线程执行了function方法,它首先会在执行前后进行出入栈,对应在jvm中就是栈桢的操作。我们直到对于一个方法的成员变量,在方法体出栈后会将内部的成员变量释放,等待下一次gc去处理它,这里不过多介绍jvm的原理,不然篇幅过长,可以参考我之前的文章:《jvm内存模型》,那么我们在function中创建了一个ThreadLocal对象,先进行赋值,再获取值。在创建ThreadLocal时用local指向ThreadLocal的对象地址,它们是强引用;再调用set方法后,当前线程的ThreadLocalMap会创建一个k-v实例,并且key使用WeakReference来包装弱引用;当function执行完成后,出栈,栈帧被销毁,这时强引用的local也将不存在,但是ThreadLocalMap中对应的key还指向ThreadLocal实例,如果这时key是强引用,那么key引用的ThreadLocal和value都无法被gc,例如下面灰色区域,key强引用ThreadLocal实例,那这个ThreadLocal将无法被回收。

  我们更建议使用static final 来修饰ThreadLocal,针对一个线程内所有的操作都是共享的。推荐使用static来修饰,是因为静态变量在加载初始化时会创建一次,只会分配一次空间,这样所有的类实例都会使用相同的存储空间;为了确保ThreadLocal的唯一性,使用final修饰符进行修饰,当然如果是私有的ThreadLocal,搭配private进行修饰保证调用的范围。但是如果我们使用static final修饰ThreadLocal,它会带来的问题是静态资源中的key在生命周期中永远是非null,导致entry一直存在,所以在使用static final时推荐搭配remove进行手动释放。

  其实我们观察ThreadLocal,不难发现,它其实就是一种空间换时间的思路。每个线程保持自己的一份本地变量,从而避免多个线程在操作同一资源时产生并发问题。这种方式也是另一种无锁编程,当然无锁不仅仅这么简单,下面要针对jvm 进行锁的分析,包括jvm的锁、aqs的无锁思想、偏向锁和锁升级。

  

  后面的篇幅,着重梳理jvm内置锁,为什么提前要介绍ThreadLocal呢,因为它本身就是一种无锁的思想。jvm中的锁可以算得上重头戏,在jdk不断升级完善的过程中,从轻量锁,锁升级,重入,到新版本追随go的脚步,实现携程虚拟线程...jvm的内卷也一步步增加了

  在java中,锁是互斥的。意味着最多只有一个线程可以获取到锁,当有多个线程且只有一个线程获取到相同的锁后,其他线程必须等待或者阻塞,直到获取锁的线程释放了锁。

  • 线程安全

  线程安全是指:当多个线程访问相同的共享变量时,达到的结果符合我们预期的、正确的行为。相反:如果多个线程操作出现了错误的、达不到预期的结果,我们也称它们是线程不安全的。

  • 自增操作是线程不安全的

    一个老生常谈的问题。i++ 是不是线程安全的?为什么?

    我们来做个实验:

 1     private static int count = 0;
 2 
 3     public static void main(String[] args) {
 4         Runnable runnable = new Runnable() {
 5             @Override
 6             public void run() {
 7                 for (int i = 0; i < 10000; i++) {
 8                     count++;
 9                 }
10             }
11         };
12 
13         Thread thread1 = new Thread(runnable);
14         Thread thread2 = new Thread(runnable);
15 
16         thread1.start();
17         thread2.start();
18 
19         try {
20             thread1.join();
21             thread2.join();
22         } catch (InterruptedException e) {
23             e.printStackTrace();
24         }
25 
26         System.out.println("Count: " + count);
27     }
View Code

    我们让两个线程各执行1000次++操作,会发现得到的结果达不到我们的预期,每次执行都会有新的结果,我们可以得到结论:自增运算不是线程安全的。实际上自增运算并不是一个原子操作,而是被划分成了几个操作的复合操作,这个例子在《神奇的volatile》一文中也有明确的说明,这里不做过多介绍。

  • 临界区

    临界区,通常表示一个公共资源,它可能被多个线程访问,但是我们希望它每次只能被一个线程访问,在同一时间其他线程想要访问它们必须阻塞等待。在简单的开发中,我们常常会希望代码是以串行的方式执行的,但是如果有多个线程并发执行就会出现意料之外的后果。

 

  • synchronized关键字

  在java中,我们最熟悉的莫过于synchronized关键字,在java中每个对象都对象头,对象头中都有一个内置的锁,当我们使用synchronized后相当于调用synchronized获取锁,所以synchronized可以对代码进行加锁,保证只有一个线程可以获取到锁。

  • synchronized方法

  当使用synchronized修饰一个方法时,该方法为同步方法,在当前方法返回之前,任何时间只会有同一个线程进入同步方法,如果此时有其他线程都需要执行这个同步方法,那么其他线程会去等待。

public synchronized void increment() {
        for (int i = 0; i < 10000; i++) {
            count++;
        }
        System.out.println("Count: " + count);
    }
View Code
  • synchronized代码块

  对于小的临界区,如果我们直接在方法上声明synchronized,可以避免线程竞争;但是对于较大的临界区,为了执行效率,最好将大的临界区拆分成小的临界区:

 1 public class SynchronizedExample {
 2     private int count = 0;
 3     private Object lock = new Object();
 4 
 5     public static void main(String[] args) {
 6         SynchronizedExample example = new SynchronizedExample();
 7         example.increment();
 8     }
 9   
10     public void increment() {
11         for (int i = 0; i < 10000; i++) {
12             synchronized (lock) {
13                 count++;
14             }
15         }
16         System.out.println("Count: " + count);
17     }
18 }
View Code

  在synchronized后括号中的对象,代表进入临界区需要获取的对象锁。我们直到在jvm中,每个对象都有一个监视器(Monitor),因此任何对象都可以作为synchronized的同步锁,在上面的例子中放弃了在方法上使用synchronized,而是通过synchronized来拆分了临界区。在这种方式下,多个线程同样可以并发执行increment方法,但是同时只有一个线程可以进入临界区。那么synchronized方法和synchronized代码块有什么区别呢?简单地说synchronized方法时一种比较粗的控制,它的作用范围与整个方法上;而synchronized代码块相对较为精细,在synchronized代码块之外的代码还是可以并发执行的,并不一定所有的代码都需要强制单线程执行。而在synchronized方法其实就是一个synchronized代码块,只不过它的代码块包含了这个方法中所有的代码,所以他们本质来说没有什么区别,就像我们这样写:

1 private int count = 0;
2 public void increment() {
3         synchronized(this) {
4             count++;
5         }
6     }
7 public synchronized void increment() {
8         count++;
9     }
View Code
  • 静态同步

  在java中,一切皆为对象。java对象在编译时会加载成class对象。在class对象中,包含了这个对象的类、名称、属性、方法等等...我们无法通过构造创建一个class对象,它只有在类加载到jvm通过defineClass创建。所有的类都是在第一次使用被动态加载到jvm中,jvm为动态加载机制配套了一个判定动态加载的行为,类加载器首先检查这个class是否被加载,如果没有被加载,则根据类全路径查找class并加载到jvm方法区内存。

  普通的synchronized,同步锁是锁当前对象的this的monitor,如果修饰synchronized同时是static修饰的,因为static修饰的方法属于class实例而不是object、静态资源在创建就会被分配,静态方法中是无法使用this指针的,所以synchronized搭配static后无法获取object的this对象监听器。实际上,使用synchronized搭配static时,synchronized锁的并不是object对象,而是class对象中的监视器。一个类对象的实例可能有很多,但是它们只会有一个class对象,所以在使用synchronized搭配static时,会导致jvm内所有线程互斥。这也导致一个jvm内所有线程竞争都是一把锁,颗粒度非常粗。

  在synchronized的同步锁,会在代码或方法正常完成退出后释放,当然在异常情况也会自动释放。所以synchronized不需要担心特殊情况导致无法释放的问题。

 

  • java对象结构与内置锁

  摘自《java并发线程》中,提供了基于java对象的描述:

  不同的jvm对象结构不一致,下文中以hotspot jvm为例:Hotspot对象并没有将java实例的对象一比一映射到本地native的C++对象中,而是设计了一个oop-klass对象:
    我们参考hotsopt的源码可知:

1 class oopDesc {
2   friend class VMStructs;
3   friend class JVMCIVMStructs;
4  private:
5   volatile markOop _mark;
6   union _metadata {
7     Klass*      _klass;
8     narrowKlass _compressed_klass;
9   } _metadata;
 1 class instanceOopDesc : public oopDesc {
 2  public:
 3   // aligned header size.
 4   static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }
 5 
 6   // If compressed, the offset of the fields of the instance may not be aligned.
 7   static int base_offset_in_bytes() {
 8     // offset computation code breaks if UseCompressedClassPointers
 9     // only is true
10     return (UseCompressedOops && UseCompressedClassPointers) ?
11              klass_gap_offset_in_bytes() :
12              sizeof(instanceOopDesc);
13   }
14 
15   static bool contains_field_offset(int offset, int nonstatic_field_size) {
16     int base_in_bytes = base_offset_in_bytes();
17     return (offset >= base_in_bytes &&
18             (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
19   }
20 };
 1 class arrayOopDesc : public oopDesc {
 2   friend class VMStructs;
 3   friend class arrayOopDescTest;
 4 
 5   // Interpreter/Compiler offsets
 6 
 7   // Header size computation.
 8   // The header is considered the oop part of this type plus the length.
 9   // Returns the aligned header_size_in_bytes.  This is not equivalent to
10   // sizeof(arrayOopDesc) which should not appear in the code.
11   static int header_size_in_bytes() {
12     size_t hs = align_up(length_offset_in_bytes() + sizeof(int),
13                               HeapWordSize);
14 #ifdef ASSERT
15     // make sure it isn't called before UseCompressedOops is initialized.
16     static size_t arrayoopdesc_hs = 0;
17     if (arrayoopdesc_hs == 0) arrayoopdesc_hs = hs;
18     assert(arrayoopdesc_hs == hs, "header size can't change");
19 #endif // ASSERT
20     return (int)hs;
21   }
22 }

    上面版本是基于jdk11 hotspot中定义的c++源码,位于hotspot.share.oops.oop.hpp及其子类。

    opp:普通对象指针,表示对象的实例信息,从名字来看是一个指针,实际并不仅仅是一个内存地址,而是内存地址的一个描述或者对内存中数据结构的描述。所以jvm对象类被定义为oppDesc:每当在java代码中创建一个对象时,jvm会创建一个instanceOopDesc实例来表示这个对象,此对象会放在堆区。类似的,当java代码创建一个数组时,jvm会创建一个arrayOopDesc实例来表示,所以我们认为一个普通的java对象底层是一个instanceOopDesc实例。

 1 class InstanceKlass: public Klass {
 2   friend class VMStructs;
 3   friend class JVMCIVMStructs;
 4   friend class ClassFileParser;
 5   friend class CompileReplay;
 6 
 7  public:
 8   static const KlassID ID = InstanceKlassID;
 9 
10  protected:
11   InstanceKlass(const ClassFileParser& parser, unsigned kind, KlassID id = ID);
12 
13  public:
14   InstanceKlass() { assert(DumpSharedSpaces || UseSharedSpaces, "only for CDS"); }
15 
16   // See "The Java Virtual Machine Specification" section 2.16.2-5 for a detailed description
17   // of the class loading & initialization procedure, and the use of the states.
18   enum ClassState {
19     allocated,                          // allocated (but not yet linked)
20     loaded,                             // loaded and inserted in class hierarchy (but not linked yet)
21     linked,                             // successfully linked/verified (but not initialized yet)
22     being_initialized,                  // currently running class initializer
23     fully_initialized,                  // initialized (successfull final state)
24     initialization_error                // error happened during initialization
25   };

  对于hotspot的代码片段,我们可以理解问,对于jvm来说,它会给加载的类创建一个InstanceKlass对象,用在jvm表示元数据对象。但是这个InstanceKlass对象就是给jvm内部使用的,并不暴露给java层,而在java层使用的类元数据对象是java.lang.Class对象,也就是Class类的实例对象。总体而言,java对象Object结构包括三部分:对象头、对象体和对象字节

  对象头:对象头包括mark word、class pointer、array length:

    mark word:用于存储自身运行时数据例如GC标记位、哈希码、锁状态等...

    class pointer:指针,用于存放此对象的元数据InstanceKlass地址,虚拟机它通过此指针可以确定是哪个类的实例

    array length:数组长度,如果是一个数组,那么包含此字段,用于记录数组的长度

  对象体:包含对象的实例变量,用于成员属性,包括弗雷成员属性值。这部分按4字节对齐

  对齐字节:对齐字节也叫填充对齐,保证java对象所占的内存字节数为8的倍数。HotSpot VM内存管理要求对象起始位置必须是8的字节整数倍。对象头本身是8的倍数,当对象实例变量数据不是8的倍数时,需要填充数据来保证8字节对齐

 

  在对象结构中,mark word、class pointer、array length都与jvm的位数有关,32位的jvm mark word、class pointer均为一个word长度:32位;在64位jvm下mark word、class pointer均为一个word长度:64位。但是在64位jvm下,如果jvm的对象数量过多,64位将会比32位多浪费出将近50%内存。为了节约内存可以设置UseCompressedOops来开启指针压缩。开启后以下对象将会被压缩指针到32位:

  1. Class对象指针(静态变量)
  2. Object对象指针(成员变量)
  3. 普通对象数组元素指针

  在堆内存小于32G时,64位虚拟机会默认开启UseCompressedOops:

  java -XX:+UseCompressedOops mainClass

  

  • Mark word结构

  java的内置锁,就存储在对象结构中,并且存储在对象头 mark word中。mark word的长度由jvm的位数决定,不会受到压缩指针的影响。

  java内置锁状态有四种,分别为:无锁、偏向锁、轻量级锁和重量级锁。在jdk1.6之前,内置锁是一个重量级锁,效率低下。在jdk1.6之后,jvm为了提高锁的获取与释放效率,堆synchronized实现进行了优化,引入了偏向锁、轻量级锁的实现。并且这四种所会随着竞争情况升级,而且不可降级。

在《jvm锁降级》中有这样一篇文章介绍了锁的降级,内置锁是否支持锁降级,这里不做过多讨论,有兴趣同学可以通过其他文档来查找

  我们忽略32位,主要以64位来说明:

   lock:锁标记位,占两个二进制位,希望用尽可能少的二进制表示尽可能多的信息,所以设置了lock标记

  biased:用于表示是否是偏向锁,用一个二进制位表示

  使用lock和biased两个组合来表示Object实例处于一个什么锁状态,它们的状态可以这样区分:

  

  • 锁升级

  我们上面介绍了,基于对象头存储了内置锁的信息,并且在1.6之后内置锁做了优化升级,主要针对锁获取和释放做了优化。首先我们要直到这几种状态对应的锁的表现:

  1. 无锁:java对象创建完成后还没有线程竞争,这时它是无锁状态
  2. 偏向锁:指相同的同步代码被同一个线程访问,那么线程会默认获取锁,降低竞争获取锁的代价。当这个线程执行同步代码块时,不需要做任何检查和切换,偏向锁在竞争不激烈时效率很高,通过线程ID记录偏向的线程
  3. 轻量级锁:当有两个线程竞争同一把锁时,两个线程公平竞争,其中一个线程获取到锁后,会将mark word中记录当前线程帧栈;当锁处于偏向锁又被另一个线程尝试占用时,会撤销偏向锁,锁会升级为轻量级锁。尝试获取锁的线程以自旋的方式获取锁,不会阻塞当前线程,以便提高性能。自旋其实非常简单,如果持有锁的线程在短时间内释放资源,那么等待锁的线程不需要做内核态和用户态的切换进入阻塞挂机,而是等一等...等待释放后尝试获取锁;但是一直自旋是消耗cpu的,如果一直获取不到锁,那么线程不能一直自旋等待,需要让线程自旋一段时间后阻塞休眠,这时就需要直到要自旋多久放弃,在jdk1.6之后引入了自适应性自旋锁,它其实就是由上一次当前锁的自选时间和锁的状态决定的。如果线程上一次自旋成功了,下一次就会更多的自旋...如果自旋失败了,那么就要尽可能少的自旋...
  4. 如果其他线程自旋且失败了,超过了最大的自旋时间,那么线程会放弃自旋,将锁升级为重量级锁。重量级锁会让尝试获取锁的线程进行阻塞,性能降低。重量级锁也叫同步锁,这时mark word会指向一个monitor监视器,这个监视器对象用来记录排队的线程。

 

  • 偏向锁

  在实际开发场景中,如果一个同步代码块,只有一个线程多次重入获取锁,就不需要阻塞线程,唤醒cpu从用户态转为内核态,这就是偏向锁最根本的概念。

  偏向锁的原理是,如果一个不存在线程竞争的线程获取锁,那么锁就进入偏向状态,此时lock会改为01,biased会改为1,然后将线程ID使用CAS记录在mark word中。后续线程进入同步代码块时可以通过线程ID和标志位,不需要做任何同步操作。因为在jvm中,大多数情况可能线程并不存在竞争,而是总是由同一个线程获取到锁,从而提升锁的性能;同理如果多个线程竞争频繁,那么偏向锁就是多余的,撤销偏向也会带来一定的性能开销。在jvm中,启用偏向锁会延迟4秒开启,意味着刚创建的对象是不会开启偏向锁的,4秒后创建的对象才会开启偏向锁:因为jvm在启动时有一系列复杂的动作,比如装载配置、初始化。这个过程会有大量的synchronized进行加锁,且大多数锁都会存在竞争,为了减少初始化时间,jvm默认延迟加载偏向。当然我们也可以通过设置jvm的参数来禁止延迟:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

 

  • 偏向锁升级和撤销

  偏向锁的升级其实很简单,当多个线程竞争后发现当前锁已经偏向(存储了线程ID)并且线程ID不是自己,那么就说明出现了线程竞争,就会尝试撤销偏向锁然后将锁升级为轻量级锁。

  在《java并发编程》中描述到:撤销偏向锁的条件为:1.多个线程产生竞争;2.调用偏向锁对象obj的obj.hashCode()或者System.identityHashCode()计算对象哈希码之后。为什么获取hashCode时会撤销偏向锁呢?因为偏向锁中没有存储mark word的哈希码,由上面对象头的设计中我们不难看出,轻量级锁会在栈帧值得lock record记录哈希码,而重量级锁会在monitor中记录哈希码,这样对哈希码起到备份作用。但是在偏向锁中存储的是偏向线程的ID,并没有存储哈希码,所以在调用哈希码的方法时会撤销偏向锁,当锁可偏向后,mark word将变成未锁定状态,并且只能升级成轻量级锁;当对象处于偏向锁时,调用哈希码方法会将偏向锁撤销后强制升级成重量级锁。

  偏向锁的撤销代价还是很大的:

  1. jvm需要获取一个安全点:softPoint(在以前的文章中可以参考关于jvm gc的文章中介绍了安全点)《关于jvm内存模型及GC调优》,当jvm到达安全点后,所有的用户线程将被停止(stw),当然偏向的用户线程也不例外
  2. 遍历线程栈帧,检查是否存在锁记录。如果存在,就需要清空锁记录,变为无锁的状态,并修复mark word指向,清除存储的偏向线程ID
  3. 将当前锁升级为轻量级锁,或者重量级锁
  4. 唤醒当前线程

 

  偏向锁的升级:如果当前锁已经是偏向锁,那么一定会存储偏向线程的ID。此时如果有其他线程尝试抢占锁,因为偏向锁不会主动释放,所以第二个线程一定可以看到内置锁的偏向状态,那么就表明这个锁其实已经存在竞争条件了。jvm去检查原来持有锁的线程是否存活,如果挂了那么就将对象变为无锁状态,并且重新偏向;如果线程依然存活,就表明原来的线程还在使用偏向锁,那么就需要将锁升级为轻量级锁。

  在很多情况下,其实进入同一个代码块的线程会是相同的线程,这也就是jvm优化偏向锁的目的。

 

  • 轻量级锁

  轻量级锁的目的主要是在多线程竞争不激烈的情况下,通过CAS机制减少下性能损耗。

  重量级锁使用了操作系统的互斥(Mutex Lock),会在用户态和内核态间来回切换,从而带来较大的性能损耗。而轻量级锁就是为了避免这种情况。很多锁对象状态可能很快就会被释放,在短时间内阻塞并唤醒线程显然不值得,为此引入了轻量锁的概念。轻量锁是一种自旋锁,它只需要在jvm层面进行自旋解决线程同步问题。

  轻量锁执行过程:

  1. 线程进入临界区之前,如果内置锁没有锁定,jvm在lock record中记录一个锁记录,用于存储对象目前mark word的拷贝
  2. 然后进行CAS自旋,抢锁线程通过CAS自旋,尝试将内置锁对象头的ptr_to_lock_record更新为当前抢占锁线程栈帧中的记录地址,如果这个操作成功了就表示这个线程已经获取到当前的锁,然后将lock标记修改为00轻量锁
  3. CAS更新成功后,会返回旧值。这时线程会将旧的mark word备份,在释放之后会将旧值恢复到锁的对象头(内置锁对象mark word会发生改变,会出现指向锁的指针,而无锁状态下会存储对象的hash信息)

  

  轻量锁中包含了两种轻量锁,就是上文中说到的默认轻量锁和自适应轻量锁:

  默认轻量锁:轻量锁执行CAS是需要消耗CPU的,所以不能让轻量锁无休止的自旋,这也说明轻量锁更适合哪些临界区耗时很短的场景。默认情况下自旋次数为10次,可以通过-XX:PreBlockSpin来修改

  自适应轻量锁:自旋次数不固定,而是依靠上一次自旋的结果和自旋的次数决定,可以理解为:如果上一次自旋成功,那么我认为这一次应该也能成功;如果上一次失败,那么这次可能也没办法成功。

 

  • 轻量锁的升级

  我们希望轻量级锁都用于耗时很快的同步块中,但是有时候总是事与愿违。那么如果一个线程执行同步块很慢,那么会导致什么问题?过大的自旋导致CPU性能消耗大,所以当竞争激烈的情况下,轻量锁会升级为重量级锁(Mutex Lock)

 

  • 重量级锁

  在jvm中,包含了一个监视器monitor,它包含了对象头和对象信息,它相当于一个令牌。本质上,监视器是一种同步工具,也是一种同步机制:获取到令牌的人先...只有一个令牌可以被获取...获取到令牌后需要返还...

  那么我们可以将这个思想具体拆分:

  1. 同步,互斥执行,因为只有一个令牌
  2. 通信,一个线程拿着令牌执行完成,其他线程全部阻塞休眠。那么怎么让它们直到这个令牌已经空闲了

 

  在ObjectMonitor.hpp hotSpot源码中是这么写的

 1 class ObjectMonitor {
 2  public:
 3   enum {
 4     OM_OK,                    // no error
 5     OM_SYSTEM_ERROR,          // operating system error
 6     OM_ILLEGAL_MONITOR_STATE, // IllegalMonitorStateException
 7     OM_INTERRUPTED,           // Thread.interrupt()
 8     OM_TIMED_OUT              // Object.wait() timed out
 9   };
10 
11  private:
12   friend class ObjectSynchronizer;
13   friend class ObjectWaiter;
14   friend class VMStructs;
15 
16   volatile markOop   _header;       // displaced object header word - mark
17   void*     volatile _object;       // backward object pointer - strong root
18  public:
19   ObjectMonitor*     FreeNext;      // Free list linkage
20  private:
21   DEFINE_PAD_MINUS_SIZE(0, DEFAULT_CACHE_LINE_SIZE,
22                         sizeof(volatile markOop) + sizeof(void * volatile) +
23                         sizeof(ObjectMonitor *));
24  protected:                         // protected for JvmtiRawMonitor
25   void *  volatile _owner;          // pointer to owning thread OR BasicLock
26   volatile jlong _previous_owner_tid;  // thread id of the previous owner of the monitor
27   volatile intptr_t  _recursions;   // recursion count, 0 for first entry
28   ObjectWaiter * volatile _EntryList; // Threads blocked on entry or reentry.
29                                       // The list is actually composed of WaitNodes,
30                                       // acting as proxies for Threads.
31  private:
32   ObjectWaiter * volatile _cxq;     // LL of recently-arrived threads blocked on entry.
33   Thread * volatile _succ;          // Heir presumptive thread - used for futile wakeup throttling
34   Thread * volatile _Responsible;
35 
36   volatile int _Spinner;            // for exit->spinner handoff optimization
37   volatile int _SpinDuration;
38 
39   volatile jint  _count;            // reference count to prevent reclamation/deflation
40                                     // at stop-the-world time.  See deflate_idle_monitors().
41                                     // _count is approximately |_WaitSet| + |_EntryList|
42  protected:
43   ObjectWaiter * volatile _WaitSet; // LL of threads wait()ing on the monitor
44   volatile jint  _waiters;          // number of waiting threads
45  private:
46   volatile int _WaitSetLock;        // protects Wait Queue - simple spinlock

  摘取部分代码:

  _waitSet:用ObjectWaiter实现的链表

  _cxq:用ObjectWaiter实现的竞争队列

  _EntryList:候选线程队列

  简单来看呢,其实也很清晰,回想起aqs的阻塞队列,将新来的所有线程都放入cxq,然后进过候选的线程放入enrtyList,再通过合适的时机将队列中的线程唤醒.... jvm的源码超出了认知... 不做过多解析,感兴趣的朋友自行下载openjdk hotspot源码进行查看

 

  • 线程通信

  线程通信相关不在本章做详细介绍,关于wait、notify等等.. 请参考《关于LockSupport》这里有对于基本线程间通信的内容。

标签:synchronized,对象,编程,并发,ThreadLocal,线程,jvm,class
From: https://www.cnblogs.com/oldEleven/p/17865903.html

相关文章

  • 实验6 c语言结构体、枚举应用编程
    实验任务4程序源码1#include<stdio.h>2#defineN1034typedefstruct{5charisbn[20];//isbn号6charname[80];//书名7charauthor[80];//作者8doublesales_price;//售价9intsales_......
  • CodeGeeX智能编程
    一、写在前面大家遇到代码不会的问题,本能的就会去求助chatGPT,但是没有梯子的话,chatGPT是不是也帮不上忙了?秉着白嫖的精神,分享给大家一款非常牛的插件CodeGeex。二、CodeGreex简介CodeGreex支持多种主流IDE,如VSCode、IntelliJIEAD、PyCharm、vim等,同时支持Python、java、C++/C......
  • 高并发情况下的漏桶算法(javascript版)
    classLeakyBucket{//高并发情况下的漏桶算法 constructor(capacity,leakRate){//创建一个容量为capacity,每秒漏水量为leakRate的漏桶 this.capacity=capacity; this.leakRate=leakRate; this.water=0; this.lastLeakTime=Date.now(); ......
  • 实验6 C语言结构体、枚举应用编程
    一、实验目的二、实验准备三、实验内容四、实验结果1.实验任务4源代码:1#include<stdio.h>2#defineN1034typedefstruct{5charisbn[20];//isbn号6charname[80];//书名7charauthor[80];//作者8......
  • C++基础 -4- C/C++混合编程
    ———————C/C++混合编程———————......
  • Java多线程编程
    本文中简单介绍一些java多线程相关的内容1.多线程基础Java通过java.lang.Thread类和java.util.concurrent包提供了多线程支持。一个线程可以通过继承Thread类或实现Runnable接口来创建。classMyThreadextendsThread{publicvoidrun(){//线程执行的代码}......
  • 实验6 C语言结构体,枚举应用编程(附实验5 C语言指针应用编程)
    实验6一,实验目的二,实验准备三,实验内容1,实验任务1task1.c1#include<stdio.h>2#include<string.h>3#defineN3//运行程序输入测试时,可以把这个数组改小一些输入测试45typedefstructstudent{6intid;//学号7......
  • 实验6 C语言结构体、枚举应用编程
    四、实验结论4.实验任务4task4.c1#include<stdio.h>2#defineN1034typedefstruct{5charisbn[20];//isbn号6charname[80];//书名7charauthor[80];//作者8doublesales_price;//售价9......
  • Java第七课_面向对象编程
    1.面向对象的编程对象publicclassPractice01{publicstaticvoidmain(String[]args){/*面向对象的编程,将一切事项都视为对象.对象用类来描述,过程在类里实现.使用时只需要调用类,不需要再考虑如何实现.将对象的共同特征进行......
  • C#泛型编程:深入探究泛型的威力
    文章目录泛型(Generic)泛型(Generic)的特性泛型约束派生约束构造函数约束值约束引用约束多个泛型参数泛型类继承泛型约束泛型方法泛型方法的重载泛型方法的重写虚方法泛型泛型委托泛型强转泛型参数隐式强制转换泛型参数显示强制转换泛型参数强制转换到其他任何类......