上一篇
多线程学习(一)之多线程的概述
使用多线程技术可以提升程序的处理性能,提升并发吞吐量等等。多线程好是好,但是使用多线程就会带来一个问题:线程安全性问题。什么是线程安全?接下来我们就一起学习下
线程安全
线程安全本质上是管理对于数据状态的访问
涉及到线程安全的数据状态有两个特点
- 共享的:是指这个数据变量可以被多个线程访问;
- 可变的:指这个变量的值在它的生命周期内是可以改变的。
线程安全的对象
在开发或者面试的时候,经常会有人问到什么对象是线程安全的?那么到底什么是线程安全的对象呢?
如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。
线程安全的类
- Vector :Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList慢。
- HashTable
- StringBuffer
线程不安全的类
- ArrayList :
- LinkedList:
- HashMap:
- HashSet:
- TreeMap:
- TreeSet:
- StringBulider:
相关集合对象比较:
Vector、ArrayList、LinkedList:
- Vector:
Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList慢。 - ArrayList:
a. 当操作是在一列数据的后面添加数据而不是在前面或者中间,并需要随机地访问其中的元素时,使用ArrayList性能比较好。
b. ArrayList是最常用的List实现类,内部是通过数组实现的,它允许对元素进行快速随机访问。数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要讲已经有数组的数据复制到新的存储空间中。当从ArrayList的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。 - LinkedList:
a. 当对一列数据的前面或者中间执行添加或者删除操作时,并且按照顺序访问其中的元素时,要使用LinkedList。
b. LinkedList是用链表结构存储数据的,很适合数据的动态插入和删除,随机访问和遍历速度比较慢。另外,他还提供了List接口中没有定义的方法,专门用于操作表头和表尾元素,可以当作堆栈、队列和双向队列使用。
Vector和ArrayList在使用上非常相似,都可以用来表示一组数量可变的对象应用的集合,并且可以随机的访问其中的元素。
HashTable、HashMap、HashSet:
HashTable和HashMap采用的存储机制是一样的,不同的是:
-
HashMap:
a. 采用数组方式存储key-value构成的Entry对象,无容量限制;
b. 基于key hash查找Entry对象存放到数组的位置,对于hash冲突采用链表的方式去解决;
c. 在插入元素时,可能会扩大数组的容量,在扩大容量时须要重新计算hash,并复制对象到新的数组中;
d. 是非线程安全的;
e. 遍历使用的是Iterator迭代器; -
HashTable:
a. 是线程安全的;
b. 无论是key还是value都不允许有null值的存在;在HashTable中调用Put方法时,如果key为null,直接抛出NullPointerException异常;
c. 遍历使用的是Enumeration列举; -
HashSet:
a. 基于HashMap实现,无容量限制;
b. 是非线程安全的;
c. 不保证数据的有序;
TreeSet、TreeMap:
TreeSet和TreeMap都是完全基于Map来实现的,并且都不支持get(index)来获取指定位置的元素,需要遍历来获取。另外,TreeSet还提供了一些排序方面的支持,例如传入Comparator实现、descendingSet以及descendingIterator等。
-
TreeSet:
a. 基于TreeMap实现的,支持排序;
b. 是非线程安全的; -
TreeMap:
a. 典型的基于红黑树的Map实现,因此它要求一定要有key比较的方法,要么传入Comparator比较器实现,要么key对象实现Comparator接口;
b. 是非线程安全的;
StringBuffer和StringBulider:
StringBuilder与StringBuffer都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串。
- 在执行速度方面的比较:StringBuilder > StringBuffer ;
- 他们都是字符串变量,是可改变的对象,每当我们用它们对字符串做操作时,实际上是在一个对象上操作的,不像String一样创建一些对象进行操作,所以速度快;
- StringBuilder:线程非安全的;
- StringBuffer:线程安全的;
线程安全性问题是怎么产生的?
我们来看一段代码
public class TestClass {
public static int count=0;
public static void incr(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main( String[] args ) throws InterruptedException {
for(int i=0;i<1000;i++){
new Thread(()-> TestClass.incr()).start();
}
Thread.sleep(3000); //保证线程执行结束
System.out.println("运行结果:"+count);
}
}
上面代码的预期结果是1000,但是结果是小于等于1000的随机数
为什么会出现这种问题呢?因为看似count++是一条命令,但其实在jvm中,他是由三个指令组成的。所以会产生原子性和可见性问题
14: getstatic #5 // Field count:I
15: iconst_1
16: iadd
17: putstatic #5
看下图
如上图所示,如果我们有一个程序,需要开两个线程对i进行累加,并且只累加一次,正常的i的期望值为3,但是现在没有加锁,也没有任何措施,这时候实际结果为2,这就是线程不安全的问题。为什么会出现这种问题呢?是因为线程在运行程序的时候,都会把值读取到线程自己的虚拟机栈中的操作数栈进行运算,所以这两个线程读到的i均为1,这时候做i++的操作,两个线程中的i值均为2,接着两个线程分别把自己的运算结果返回,因为JVM是栈式指令集架构,所以会将值覆盖,所以i的最终结果是2 而不是3。
在java中如何保证线程安全
了解数据库的同学,mysql保证数据的安全性、一致性的时候会用“锁”,什么乐观锁、悲观锁等等,那么在java中也有锁,就是synchronized。
synchronized 的基本认识
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁。
首先说一下synchronized在底层的实现,他是基于进入和退出Monitor对象(每一个对象都会有一个monitor与之关联)我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized修饰的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象对应的monitor。
monitorenter表示去获得一个对象监视器。monitorexit表示释放monitor监视器的所有权,使得其他
被阻塞的线程可以尝试去获得这个监视器
monitor依赖操作系统的MutexLock(互斥锁)来实现的,线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能
任意线程对Object(Object由synchronized保护)的访问,首先要获得Object的监视器。如果获取失
败,线程进入同步队列,线程状态变为BLOCKED。当访问Object的前驱(获得了锁的线程)释放了
锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。
synchronized 的基本语法
synchronized 有三种方式来加锁,分别是
-
修饰实例方法(锁住的是当前实例对象),进入同步代码前要获得当前实例的锁。
- 同一个实例调用会阻塞
- 不同实例调用不会阻塞
-
修饰静态方法(全局锁),进入同步代码前要获得当前类对象的锁。
- 所有调用该方法的线程都会实现同步
-
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
- 同步代码块传参this(锁住的是当前实例对象)
- 同一个实例调用会阻塞
- 不同实例调用不会阻塞
- 同步代码块传参变量对象 (锁住的是变量对象)
- 同一个属性对象才会实现同步
- 同步代码块传参class对象(全局锁)
- 所有调用该方法的线程都会实现同步
- 同步代码块传参this(锁住的是当前实例对象)
package com.xhc.test.thread.syncdemo;
public class SynchronizedTest {
// 1.修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
synchronized public void demo01(){
System.out.println("我是实例方法");
}
// 2.修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
synchronized public static void demo02(){
System.out.println("我是静态方法");
}
// 3.修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
Object obj = new Object();
public void demo03(){
System.out.println("代码块外");
synchronized(obj){
System.out.println("代码块里");
}
}
}
可以改造下面的例子,自行测试运行结果
public class SynchronizedTest {
//锁住了本类的实例对象
public synchronized void test1() {
try {
System.out.println(Thread.currentThread().getName() + " test 进入了同步方法");
Thread.sleep(5000);
System.out.println(Thread.currentThread().getName() + " test 休眠结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
SynchronizedTest st = new SynchronizedTest();
SynchronizedTest st2 = new SynchronizedTest();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " test 准备进入");
st.test1();
}).start();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " test 准备进入");
st.test1();
}).start();
}
}
锁是如何存储的
锁的升级
任何事物都是有两面性的,锁也是一样。加锁可以保证线程安全,但是会造成性能下降。不加锁不会影响性能,但是会造成数据不安全。怎么样在加锁与不加锁之间进行平衡呢?
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程 多次获得。所以基于这样一个概率,synchronized 在 JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized中,锁存在四种状态。锁的状态根据竞争激烈的程度从低到高不断升级。
- 无锁
- 偏向锁
大部分情况下,锁不仅仅不存在多线程竞争, 而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。怎么理解偏向锁呢? 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了(引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁升级。(偏向锁的目的是消除数据在无竞争情况下锁带来的性能消耗,进一步提高程序的运行性能。))
偏向锁在Java 6和Java 7是默认启动的,但是在程序启动几秒之后才可以激活,这个值默认是4秒,在这之前创建的对象就是无锁状态,可以通过-XX:BiasedLockingStartupDelay=0来关闭延迟,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false。
- 轻量级锁
如果偏向锁被关闭(在运行参数中设置) 或者当前偏向锁已经已经被其他线程获取,那么这个时候如果有线程去抢占同步锁时,锁会升级到轻量级锁。
- 重量级锁
- 多个线程竞争同一个锁的时候,JVM会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程;
- Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的:os pthread_mutex_lock() ;
- 升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指
针,此时等待锁的线程都会进入阻塞状态
对象头中锁的信息
锁膨胀示意图
锁膨胀流程图
首先我们得知道锁时只能升级不能降级的(这里说的是有线程占有锁的情况,如果没有任何一个线程占有锁,锁是可以降级成无锁的),也就是偏向锁升级成轻量级锁就不能再降级后才能偏向锁了
参考:https://blog.youkuaiyun.com/weixin_42737868/article/details/105730958
小结:
- 偏向锁只有在第一次请求时采用CAS在锁对象的标记中记录当前线程的ID,在之后该线程再次进入同步代码块时,不需要抢占锁,直接判断线程ID即可,这种适用于锁会被同一个线程多次抢占
的情况。 - 轻量级锁才用CAS操作,把锁对象的标记字段替换为一个指针指向当前线程栈帧中的LockRecord,它针对的是多个线程在不同时间段内申请同一把锁的情况。
- 重量级锁会阻塞、和唤醒加锁的线程,它适用于多个线程同时竞争同一把锁的情况。
线程之间的通信
在Java中提供了wait/notify这个机制,用来实现条件等待和唤醒。这个机制我们平时工作中用的少,但是在很多底层源码中有用到。比如以抢占锁为例,假设线程A持有锁,线程B再去抢占锁时,它需要等待持有锁的线程释放之后才能抢占,那线程B怎么知道线程A什么时候释放呢?这个时候就可以采用通信机制。
- wait
wait:表示持有对象锁的线程 A 准备释放对象锁权限,释放 cpu 资源并进入等待状态。
- notify
notify:表示持有对象锁的线程 A 准备释放对象锁权限,通知 JVM 唤醒某个竞争该对象锁的线程 X。线程 A synchronized 代码执行结束并且释放了锁之后,线程 X 直接获得对象锁权限,其他竞争线程继续等待(即使线程 X 同步完毕,释放对象锁,其他竞争线程仍然等待,直至有新的 notify ,notifyAll 被调用)。
- notifyall
notifyAll:notifyall 和 notify 的区别在于,notifyAll 会唤醒竞争同一个对象锁的所有线程,当已经获得锁的线程 A 释放锁之后,所有被唤醒的线程都有可能获得对象锁权限
需要注意的是:三个方法都必须在 synchronized 同步关键字所限定的作用域中调用,否则会报错:java.lang.IllegalMonitorStateException ,意思是因为没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。另外,通过同步机制来确保线程从 wait 方法返回时能够感知到 notify 线程对变量做出的修改