Java--线程安全与不安全&同步异步和锁

线程
  线程不安全?首先看看线程的工作原理,jvm有一个main memory,而每个线程有自己的working memory,一个线程对一个变量进行操作时,都要在自己的working memory里面建立一个copy,操作完之后再写入main memory。多个线程同时操作同一个变量(variable),就可能会出现不可预知的结果,即线程不安全。

  而用synchronized(同步)的关键是建立一个monitor,这个monitor可以是要修改的变量,也可以是其他你认为合适的object比如method,然后通过给这个monitor加锁来实现线程安全,每个线程在获得这个锁之后,要执行完 load到working memory -> use&assign -> store到main memory的过程,才会释放它得到的锁。这样就实现了所谓的线程安全。
  总结:synchronized 使一段代码同时只能有一个线程来操作,其实就是给对象加了锁,来实现线程安全。

一、 基本概念

线程安全:当多个线程访问某一个类(对象或方法)时,这个类始终都能表现出正确的行为,那么这个类(对象或方法)就是线程安全的。

线程不安全:当多个线程对同一个对象中的同一个实例变量进行操作时会出现值被更改、值不同步的情况,进而影响程序的执行流程。

同步:发送一个请求,等待返回,然后再发送下一个请求
异步:发送一个请求,不等待返回,随时可以再发送下一个请求

阻塞:是指调用结果返回之前,当前线程会被挂起。函数只有在得到结果之后才会返回。
非阻塞:在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

二、同步与阻塞的区别

阻塞有一个很明显的特征就是Blocking,有了这个特征才叫做阻塞。在java程序中的阻塞线程通常处于Blocking状态

同步通常是指步骤需要一步步来完成,就按常规的代码一条条的执行下去。相对于阻塞状态,同步的线程应当处于Running状态

:线程处于Blocking状态就差不多可以看成是休眠了,就是什么也没法做,只有等待信号将他唤醒而Running状态的线程是活跃的。在这种状态下可以去做很多的事情。

三、什么会引发线程不安全?

  • 引发线程不安全必须满足三个条件:
  1. 有共享变量
  2. 处在多线程环境下
  3. 共享变量有修改操作。

:若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
总结:线程安全问题都是由全局变量及静态变量引起的。

四、Java中常见的线程安全与不安全的集合?

ArrayListLinkedList 线程不安全Vector 线程安全

HashMap 线程不安全HashTable 线程安全

StringBuilder 线程不安全StringBuffer 线程安全

五、有哪些方法能解决线程不安全?

  • 所谓解决线程安全问题无非就是将操作原子化原子化可以使用原子类,加sychronized,或者加lock,只要将操作原子化就能避免线程安全的问题。即解决线程不安全的方法
    1. 使用局部变量:优先考虑能否不用共享,优先使用局部变量代替共享的全局变量
    2. 使用原子类:只能用共享变量的时候优先使用原子类,诸如AtomicInteger等等。 没有原子类,可以自己创造自己的原子类。
    3. 使用sychronized或锁:上面方法都不行,再考虑使用sychronized,lock之等等,别一上来就加sychronize,锁会有性能问题。

5.1. 【使用局部变量(略)】
5.2. 【使用原子类】
volatile对于单个的共享变量的读/写具有原子性,但是像num++(1.读取 2.加一 3.写入 三步组成)这种复合操作,volatile无法保证其原子性。在并发环境下,如果不做任何同步处理,就会有线程安全问题。最直接的处理方式就是加锁。

1
2
3
synchronized(this){
num++;
}

a. 但是使用这种独占锁机制来解决,是一种悲观的并发策略每次操作数据的时候都认为别的线程会参与竞争修改,所以悲观)。这种直接加锁,同一刻只能有一个线程持有锁,那其他线程就会阻塞。线程的挂起恢复会带来很大的性能开销,尽管jvm对于非竞争性的锁的获取和释放做了很多优化,但是一旦有多个线程竞争锁,频繁的阻塞唤醒,还是会有很大的性能开销的。所以,使用synchronized或其他重量级锁来处理显然不够合理。
b. 针对num++这类复合类的操作,可以使用java并发包中的原子操作类原子操作类。相比锁机制,使用原子类更精巧轻量,性能开销更小。这属于乐观的解决方案(非阻塞;认为别的线程不会参与竞争修改,所以乐观),也不加锁。如果操作成功了那最好;如果失败了,比如中途确有别的线程进入并修改了数据(依赖于冲突检测),也不会阻塞,可以采取一些补偿机制,一般的策略就是反复重试。很显然,这种思想相比简单粗暴利用锁来保证同步要合理的多。

- **引发思考**
    - `volatile为什么不能保证符合操作其原子性?`
    - `针对num++这类复合类的操作,为什么能使用java并发包中的原子类?(提到的一般策略)原理是什么?`
    - 【回答问题一】:
    volatile是一种轻量级的同步机制,具有可见性,上面提到每个线程都有它自己私有内存(working memory)。**所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的**。即:1`.当写一个volatile变量时,JMM会把该线程对应的私有内存中的变量强制刷新到主内存中去`;`2.这个写操作会导致其他线程中之前的缓存无效,感知到更改。num++`(1.读取 2.加一 3.写入 三步组成)在多线程环境下,有可能线程A将num读取到本地内存中,此时其他线程可能已经将num增大了很多,线程A依然对过期的num进行自加(A线程认为是最新的num),重新写到主存中,最终导致了num的结果不合预期。因此volatile不适合符合操作。volatile详情以及另一个特性**禁止指令重排序优化**。参考:http://www.cnblogs.com/chengxiao/p/6528109.html
    - 【回答问题二】:
    **java并发包中的原子类其原子性操作的实现是基于CAS**(compare-and-swap)技术。CAS,表征的是一些列操作的集合,CAS算法是由硬件直接支持来保证原子性的。看一下`AtomicInteger`的源码:
    ![原子类](https://img.xiaoxiaomo.com/blog/img/synchronized02.png)

**我们可以看见volatile保证了可见性,有序性,而unsafe保证了原子性**(线程安全几个基本特征,可见性、原子性、有序性)。

- 在jdk7中incrementAndGet源码:
1
2
3
4
5
6
7
8
9

public final int incrementAndGet() {
for (;;) {
int current = get(); //获取当前值
int next = current + 1; //对当前值+1
if (compareAndSet(current, next)) //调用compareAndSet传入当前值和更新后的值进行原子操作
return next;
} //重试
}

=> 1.先获取当前的value值; 2.对value+1; 3.调用compareAndSet方法来来进行原子更新操作,底层细节即:先检查当前value是否等于current,
如果相等,则意味着value没被其他线程修改过,更新并返回true。如果不相等,compareAndSet则会返回false,然后循环继续尝试更新。
下面是jdk8类似功能的源码,其实类似
jdk8类似功能的源码

CAS的ABA问题,著名的ABA问题,这是通常只在lock-free算法下暴露的问题。我前面说过CAS是在更新时比较前值,如果对方只是恰好相同,例如期间发生了 A -> B -> A的更新,仅仅判断数值是A,可能导致不合理的修改操作。针对这种情况,Java提供了AtomicStampedReference工具类,通过为引用建立类似版本号(stamp)的方式,来保证CAS的正确性,具体用法请参考:http://tutorials.jenkov.com/java-util-concurrent/atomicstampedreference.html。

参考:https://www.cnblogs.com/chengxiao/p/6789109.html

5.3. 【使用sychronized或锁】

- `有三种方式:分别是同步代码块 、同步方法和锁机制(Lock)`
(1) 同步方法:给多线程访问的成员方法加上synchronized修饰符
![同步方法](https://img.xiaoxiaomo.com/blog/img/synchronized04.png)
(2) 同步的代码块:
![同步的代码块](https://img.xiaoxiaomo.com/blog/img/synchronized05.png)
(3) 锁机制(Lock)
Java提供的同步代码块的另一种机制,比synchronized关键字更强大也更加灵活。**这种机制基于Lock接口及其实现类(例如:ReentrantLock)**
它比synchronized关键字好的地方:
1、`提供了更多的功能。tryLock()方法的实现,这个方法试图获取锁,如果锁已经被其他线程占用,它将返回false并继续往下执行代码。`
2、`Lock接口允许分离读和写操作,允许多个线程读和只有一个写线程。`
3、`具有更好的性能`
![锁机制(Lock)](https://img.xiaoxiaomo.com/blog/img/synchronized06.png)
参考:https://blog.csdn.net/qq_39396275/article/details/74937110

六、Synchronized和ReentrantLock有什么区别?

  1. Synchronized,是Java内建的同步机制,所以也有人称其为Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。
    在Java 5以前,synchronized是仅有的同步手段,在代码中, synchronized可以用来修饰方法,也可以使用在特定的代码块儿上,本质上synchronized方法等同于把方法全部语句用synchronized块包起来。

  2. ReentrantLock,通常翻译为再入锁,是Java 5提供的锁实现,它的语义和synchronized基本相同。再入锁通过代码直接调用lock()方法获取,代码书写也更加灵活。与此同时,ReentrantLock提供了很多实用的方法,能够实现很多synchronized无法做到的细节控制,比如可以控制fairness,也就是公平性,或者利用定义条件等注意编码必须要明确调用unlock()方法释放,不然就会一直持有该锁

  • synchronized和ReentrantLock的性能不能一概而论,早期版本synchronized在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于ReentrantLock。
    ReentrantLock

七、synchronized底层如何实现?什么是锁的升级、降级?

  1. synchronized代码块是由一对儿monitorenter/monitorexit指令实现的,Monitor对象是同步的基本实现单元。

  2. 在Java 6之前,Monitor的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。

  3. 现代的(Oracle)JDK中,JVM对此进行了大刀阔斧地改进,提供了三种不同的Monitor实现,也就是常说的三种不同的锁,大大改进了其性能:
    偏斜锁(Biased Locking)、轻量级锁、重量级锁。
    锁的升级、降级:就是JVM优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。

  4. 当没有竞争出现时,默认会使用偏斜锁。JVM会利用CAS操作(compare and swap),在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。

  5. 如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖CAS操作Mark Word来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁

  • 有的观点认为Java不会进行锁降级。实际上,锁降级确实是会发生的,当JVM进入安全点(SafePoint)的时候,会检查是否有闲置的Monitor,然后试图进行降级。

八、锁的分类(名词的解释)

乐观锁/悲观锁
独享锁/共享锁
互斥锁/读写锁
可重入锁
公平锁/非公平锁
分段锁
偏向锁/轻量级锁/重量级锁
自旋锁

  1. 乐观锁/悲观锁
    这两个名词上面有提到,是一个概念或思想,主要是指看待并发同步的角度。下面再总结一下:
    乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,在Java中java.util.concurrent.atomic包下面的原子类就是使用了乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。
    悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁(排他锁),这样别人想拿这个数据就会阻塞直到它拿到锁,适合写操作非常多的场景。比如Java里面的synchronized关键字的实现就是悲观锁。

  2. 独享锁/共享锁
    独享锁:是指该锁一次只能被一个线程所持有。例如:ReentrantLock、Synchronized
    共享锁:是指该锁可被多个线程所持有。对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。
    独享锁与共享锁都是通过AQS来实现的,只是实现了不同的方法,来实现独享或者共享。

  3. 互斥锁/读写锁
    上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
    互斥锁:在Java中的具体实现就是ReentrantLock。
    读写锁:在Java中的具体实现就是ReadWriteLock。

  4. 可重入锁
    可重入锁又名递归锁是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。synchronized和ReentrantLock都是可重入锁。
    举个例子:
    当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。

    1
    2
    3
    4
    5
    6
    7
    class MyClass {
    public synchronized void method1() {
    method2();
    }
    public synchronized void method2() {
    }
    }

如果线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但问题是,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样线程A一直去尝试获取锁,就会造成死锁。

  1. 公平锁/非公平锁
    公平锁:是指多个线程按照申请锁的顺序来获取锁。否则就是非公平锁。
    ReetrantLock,默认是非公平锁,可以设置为公平锁。非公平锁的优点在于吞吐量比公平锁大。
    Synchronized,非公平锁。

  2. 分段锁
    分段锁:也叫分离锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
    ConcurrentHashMap中的分段锁称为Segment(Segment继承了ReentrantLock),内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个。
    当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
    注意:在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
    分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

  3. 偏向锁/轻量级锁/重量级锁
    这三种锁是指锁的状态,并且是针对Synchronized。上面已经提到(略)

  4. 自旋锁
    自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
    自旋锁
    lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。
    自旋锁的缺点:
    8.1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
    8.2. 上面Java实现的自旋锁不是公平的,不公平的锁就会存在“线程饥饿”问题。
    自旋锁的优点:
    自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
    非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
    自旋锁参考:https://blog.csdn.net/fuyuwei2015/article/details/83387536
    更多锁分类细节参考: https://www.cnblogs.com/hustzzl/p/9343797.html

九、死锁,解决和避免

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。
死锁
常见死锁的检测工具:JstackJConsole
避免

  1. 尽量避免使用多个锁,并且只有需要时才持有锁。
  2. 设计好锁的获取顺序,
  3. 超时放弃

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器