前言

  随着时代的发展,CPU核数的增加和计算速度的提升,串行化的任务执行显然是对资源的极大浪费,掌握多线程是每个程序员必须掌握的技巧。但是同时多线程也是一把双刃剑,带来了共享资源安全的隐患。在本节会介绍线程安全是什么、最基本的独占悲观式来保证线程安全的介绍。随着章节步步深入。

1.1 什么是线程安全?

1.1.1 初识线程安全的尴尬

  本人是17年毕业的,刚进第一家公司的时候没有开发经验,对接第三方支付公司外部API压测的时候碰到一个问题:对方要求我5次/s,一共发300s的付款请求。其中一个请求的id,保证当日每一次唯一。我请求的id从1开始递增,但是总是也达不到300*5=1500。也闹出了很尴尬的笑话。这是我第一次接触多线程。为了简化问题,例子如下:2个线程对一个数字递增加2000次,看看是否最后是2000。

/**
* 多线程递增某一个数字的测试类。
*
* @author GrimMjx
*/
public class UnsafeAdd { private int i; public int getNext() {
return i++;
} public static void main(String[] args) throws InterruptedException {
UnsafeAdd multiAdd = new UnsafeAdd(); Thread thread1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
multiAdd.getNext();
}
}); Thread thread2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
multiAdd.getNext();
}
}); thread1.start();
thread2.start(); //请结合上一章节体会为何写下面2行
thread1.join();
thread2.join(); System.out.println(multiAdd.i);
}
}

  运行的结果99%都不是自己想要的结果,说明这边出现了线程安全的问题。除了这个例子相信很多同学都会听到类似这种话"HashMap不是线程安全的"、"不可变对象一定是线程安全的"等等。这些都是在说线程安全方面的话题,之后的源码分析专题会分析为什么HashMap不是线程安全的,取而代之的ConcurrentHashMap如何保证线程安全的?同时JDK6引入ConsurrentSkipListMap和ConcurrentSkipListSet分别作为同步的SortedMap和SortedSet的并发替代品,还有用synchronizedxxx()方法包装的Map。

1.1.2 线程安全的概念

  对于线程安全的概念,参考《Java Concurrency in Practice》中的一句对线程安全的定义:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么这个类就是线程安全的。

1.1.3 Race condition(竞态条件)

  现在我们来分析一下上面的数据不一致问题,这种情况成为竞态条件,为什么会出现这个问题?UnsafeAdd的问题在于,线程的执行是由CPU时间片轮询调度的,如果执行的时机不对,那么可能在调用getNext()方法的时候得到一样的值、或者某些值被忽略等。主要是i++;看起来是一个原子操作,但是它包含了3个独立的操作:读取i,将i+1,并将计算结果写入i。简单画一张图,如下:

  

  线程A和线程B可能都读到i的变量为10,所以可能导致重复的情况,造成达不到2000的效果。

1.2 初识保证线程安全的基本方法

1.2.1 synchronized关键字

  什么是synchronized?引入一段来自JDK官网对synchronized关键字比较权威的解释:Synchronized keyword enable a simple strategy for preventing thread interference and memory consistency errors: if an object is visible to more than one thread, all reads or writes to that object's variables are done through synchronized methods. 如果一个对象对多线程是可见的,那么对改对象的读写操作都将通过同步的方式进行。网上对他的讲解千千万,很多都是一样的。接下来讲一下我对他的具体表现:

  • synchronized关键字用到的是monitor enter和monitor exit两个JVM指令(请用javap命令自行研究),且遵循happens-before规则。能保证在monitor enter,获取到锁之前必须从主内存获取数据,而不是线程的本地内存。在monitor exit之后变量会刷新到主内存。(这里和上面的图都涉及到JMM模型,这是并发的基础,后面章节会详细介绍)
  • “synchronized是一把锁”,这种理解是不严谨的。准确的来说是某线程获取了对象的monitor锁,在没有释放该锁之前,其他线程在同一时刻无法获取该锁。
  • synchronized可以用于对代码块或者方法进行修饰,不能对变量进行修饰

  如果要解决之前的问题,那么在getNext()方法上加上synchronized关键字就可以解决了问题。原因就是上面提到的,当某个线程获取了monitor锁,那么其他线程是无法获取锁的。也就是说其他线程都无法执行该方法,直到其他线程放弃该锁。每一个内置锁都有且只能有一个相关联的条件队列(这里的设计是否好呢?),当一个线程获取锁进行操作的时候,其他线程都在这个队列里等待该锁。那么解决掉问题也了解最基本的保证线程安全的方法之后,我们来看一下JDK对synchronized的优化以及synchronized的弊端。

1.2.2 synchronized的优化

  • 自旋锁
    • 自旋锁在JDK1.4引入,在JDK1.6默认开启。自旋锁到底是什么呢?之前我们说的互斥锁对性能的影响很大,Java线程是映射到操作系统的原生线程上的,如果要阻塞或者唤醒一个线程就需要操作系统的帮助,因此状态转换需要花费很多CPU时间。因为锁定的状态一般只会持续很短很短的时间,为了这段时间去挂起然后再唤醒是很不值得的。如果服务器有多个处理器,我们就可以让后面的线程稍微等等,但是并不放弃CPU执行时间,这个稍微等等的过程就是自旋。
    • 自旋锁和阻塞锁很大的区别就是是否要放弃CPU执行时间
  • 锁消除
    • 锁消除是JIT编译器对锁的具体实现所做的一种优化,如果同步块所使用的锁对象通过逃逸分析出只有一个线程会访问,那么JIT编译器在编译这个同步块的时候会消除同步
  • 锁粗化
    • 如果在一段代码中对一个对象反复加锁解锁,那么会放宽锁的范围,减少性能消耗。

如以下代码:

for(int i=0;i<100000;i++){
synchronized(this){
do();
}

粗化成:

synchronized(this){
for(int i=0;i<100000;i++){
do();
}

1.2.3 synchronized的死穴:锁是慢的

  虽然内置锁优化至今已经和显式锁相差无几,但是,它的死穴就是:锁是慢的。让我们来做一个实验,一个单线程对一个数字相加1kw次,加锁和不加锁的时间的对比。

/**
* 对比有无锁的测试类。
*
* @author GrimMjx
*/
public class CompareLockTest { private int i = 0; private int y = 0; public void addWithNoLock() {
i++;
} public synchronized void addWithLock() {
y++;
} public static void main(String[] args) {
// no lock
CompareLockTest noLockTest = new CompareLockTest();
StopWatch stopWatch = new StopWatch(); stopWatch.start();
for (int index = 0; index < 10000000; index++) {
noLockTest.addWithNoLock();
}
stopWatch.stop();
System.out.println("no lock: " + stopWatch.getTotalTimeMillis()); // with lock
stopWatch.start();
for (int index = 0; index < 10000000; index++) {
noLockTest.addWithLock();
}
stopWatch.stop();
System.out.println("with lock: " + stopWatch.getTotalTimeMillis());
}
}

  结果不加锁的大概是7毫秒,加锁大概是250毫秒。这还只是单线程,如果是多线程呢?并发很难而锁的性能糟糕。线程就像是两兄弟为一个玩具争吵,操作系统就像是父母来决定他们谁拿玩具。

1.2.4 如何加锁

  我们碰到最多的问题就是若没有则添加,我们来看一个例子,先写一个错误的加锁方式,后写一个正确的方式。

/**
* list测试类。
*
* @author GrimMjx
*/
public class ListTest { public List<String> list = Collections.synchronizedList(new ArrayList<String>()); /**
* 非线程安全
*
* @param element
* @return
*/
public synchronized boolean unsafePutIfAbsent(String element) {
boolean absent = !list.contains(element);
if (absent) {
list.add(element);
}
return absent;
} /**
* 线程安全
*
* @param element
* @return
*/
public boolean safePutIfAbsent(String element) {
synchronized (list) {
boolean absent = !list.contains(element);
if (absent) {
list.add(element);
}
return absent;
}
} // ...其他对list操作的方法
}

  第一个方法为何不是线程安全的?方法不是也已经用synchronized修饰了么?这个list也是线程安全的。对不对?问题在于在错误的锁上进行了同步,只是带来了同步的假象,这就意味着该方法相对于List的其他操作来说并不是原子的。因此无法确保当方法执行的时候,另外一个线程不会修改链表。

  第二个方法是正确的线程安全的,最重要的是因为list在外部加锁时要使用同一个锁。对于使用list的代码,使用list本身用于保护其状态的锁来保护这段代码。说白了就是你要知道你获取的什么锁,锁的是什么对象,这个是一定要搞清楚的。

1.3 死锁

1.3.1 死锁的介绍

  在多线程访问共享资源的情况下,如果对线程驾驭不当很容易引起死锁的情况发生。死锁又分:交叉锁、数据库锁等。比如说数据库锁,如果A线程执行了select xxx for update语句退出了事务,那么别的线程访问都将陷入死锁。简而言之,死锁说白了就是“我在等你,你也在等我”。还是写个例子吧。

/**
* 死锁测试类。
*
* @author GrimMjx
*/
public class DeadLockTest { public static void main(String[] args) {
Object a = new Object();
Object b = new Object(); new Thread(()->{
synchronized (a) {
System.out.println("已经锁住a了");
synchronized (b){
System.out.println("同时锁住a和b了");
}
}
}).start(); new Thread(()->{
synchronized (b) {
System.out.println("已经锁住b了");
synchronized (a){
System.out.println("同时锁住a和b了");
}
}
}).start();
}
}

  如果A线程已经获取a对象的锁,现在想要获取b对象的锁。此时B线程已经获取b对象的锁,想要获取a对象的锁。那么如果两个线程都不释放已经持有对象的锁,大家都无法拿到第二个对象的锁。如果程序出现死锁,可以利用jstack等工具进行分析。

  

Java并发专题(二)线程安全的更多相关文章

  1. java并发系列(二)-----线程之间的协作(wait、notify、join、CountDownLatch、CyclicBarrier)

    在java中,线程之间的切换是由操作系统说了算的,操作系统会给每个线程分配一个时间片,在时间片到期之后,线程让出cpu资源,由其他线程一起抢夺,那么如果开发想自己去在一定程度上(因为没办法100%控制 ...

  2. Java并发(二十一):线程池实现原理

    一.总览 线程池类ThreadPoolExecutor的相关类需要先了解: (图片来自:https://javadoop.com/post/java-thread-pool#%E6%80%BB%E8% ...

  3. Java并发编程:线程池的使用

    Java并发编程:线程池的使用 在前面的文章中,我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题: 如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了, ...

  4. Java并发编程:线程间协作的两种方式:wait、notify、notifyAll和Condition

    Java并发编程:线程间协作的两种方式:wait.notify.notifyAll和Condition 在前面我们将了很多关于同步的问题,然而在现实中,需要线程之间的协作.比如说最经典的生产者-消费者 ...

  5. Java并发编程二三事

    Java并发编程二三事 转自我的Github 近日重新翻了一下<Java Concurrency in Practice>故以此文记之. 我觉得Java的并发可以从下面三个点去理解: * ...

  6. Java并发编程:线程池的使用(转)

    Java并发编程:线程池的使用 在前面的文章中,我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题: 如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了, ...

  7. Java并发编程:线程控制

    在上一篇文章中(Java并发编程:线程的基本状态)我们介绍了线程状态的 5 种基本状态以及线程的声明周期.这篇文章将深入讲解Java如何对线程进行状态控制,比如:如何将一个线程从一个状态转到另一个状态 ...

  8. Java 并发编程:线程间的协作(wait/notify/sleep/yield/join)

    Java并发编程系列: Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) Java 并发编程 ...

  9. (转)Java并发编程:线程池的使用

    背景:线程池在面试时候经常遇到,反复出现的问题就是理解不深入,不能做到游刃有余.所以这篇博客是要深入总结线程池的使用. ThreadPoolExecutor的继承关系 线程池的原理 1.线程池状态(4 ...

  10. Java并发编程:线程池的使用(转载)

    转载自:https://www.cnblogs.com/dolphin0520/p/3932921.html Java并发编程:线程池的使用 在前面的文章中,我们使用线程的时候就去创建一个线程,这样实 ...

随机推荐

  1. tmux使用中出现的问题和解决方式

    常用操作: tmux ls 看当前都有哪些sessiontmux new -s my1 创建窗口,名为my1ctrl+B,D 退出窗口 (这个就是同时按ctrl和B,然后松开后再按D键)tmux at ...

  2. SpringCloud 在Feign上使用Hystrix(断路由)

    SpringCloud  在Feign上使用Hystrix(断路由) 第一步:由于Feign的起步依赖中已经引入了Hystrix的依赖,所以只需要开启Hystrix的功能,在properties文件中 ...

  3. 201771010118 马昕璐《面向对象程序设计java》第十二周学习总结

    第一部分:理论知识学习部分 用户界面:用户与计算机系统(各种程序)交互的接口 图形用户界面:以图形方式呈现的用户界面 AET:Java 的抽象窗口工具箱包含在java.awt包中,它提供了许多用来设计 ...

  4. pyhton中常用的基础模块

    目前在看Google的DetectionAPI源码,自己的Python功底不是很好,看到了哪些模块就随手记一下. 1.abc模块,参考https://www.cnblogs.com/wancy86/p ...

  5. Gradle 学习一

    参考教程:https://guides.gradle.org/consuming-jvm-libraries/ 安装Gradle 下载地址:https://guides.gradle.org 配置环境 ...

  6. [Educational Round 59][Codeforces 1107G. Vasya and Maximum Profit]

    咸鱼了好久...出来冒个泡_(:з」∠)_ 题目连接:1107G - Vasya and Maximum Profit 题目大意:给出\(n,a\)以及长度为\(n\)的数组\(c_i\)和长度为\( ...

  7. ckeditor+ckfinder

    官方地址:http://ckeditor.com/ 复制ckeditor和ckfinder的文件夹到项目根路径下 拷贝ckfinder的config.xml到WEB-INF下 <config&g ...

  8. Multi-Get API

    multiGet API并行地在单个http请求中执行多个get请求. Multi-Get Request MultiGetRequest构造函数为空,需要你添加`MultiGetRequest.It ...

  9. idea maven cannot resolve symbol http报错问题解决

    学习SpringMVC的过程中,在idea中使用maven管理依赖.在class中使用 javax.servlet.http.HttpServletRequest的时候,报错:cannot resol ...

  10. SQL Server Cast、Convert数据类型转换

    一.概述 本篇文章转载来着官网在线文档,文章主要介绍SQL Server数据类型转换相关语法.隐式转换.Date样式等. 语法 Syntax for CAST: CAST ( expression A ...