java线程安全问题以及使用synchronized解决线程安全问题的几种方式
一、线程安全问题
1.产生原因
我们使用java多线程的时候,最让我们头疼的莫过于多线程引起的线程安全问题,那么线程安全问题到底是如何产生的呢?究其本质,是因为多条线程操作同一数据的过程中,破坏了数据的原子性。所谓原子性,就是不可再分性。有物理常识的小伙伴可能要反驳了,谁说原子不可再分?原子里边还有质子和中子。我们不在这里探讨物理问题,我确实也没深究过为什么被称为原子性,也许是这个原则出现的时候还没有发现质子和中子,我们只要记住在编程中所提到的原子性指的是不可再分性就好了。回到正题,为什么说破坏了数据的原子性就会产生的线程安全问题呢?我们用一个非常简单的例子来说明这个问题。
我们来看下面这段非常简单的代码:
int i = 1;
int temp;
while(i < 10){
temp = i; //读取i的值
i = temp + 1; //对i进行+1操作后再重新赋给i
};
细心的小伙伴可能已经发现了,这不就是i++做的事情吗。没错,其实i++就是做了上面的两件事:
- 读取i当前的值
- 对读取到的值加1然后再赋给i
我们知道,在某一个时间点,系统中只会有一条线程去执行任务,下一时间点有可能又会切换为其他线程去执行任务,我们无法预测某一时刻究竟是哪条线程被执行,这是由CPU来统一调度的。因此现在假设我们有t1、t2两条线程同时去执行这段代码。假设t1执行完第5行代码停住了(需要等待CPU下次调度才能继续向下执行),此时t1读到i的值是1。然后CPU让t2执行,注意刚才t1只执行完了第5行,也就是说t1并没有对i进行加1操作然后再赋回给i,因此这是i的值还是1,t2拿到i=1后一路向下执行直到结束,当执行到第6行的时候对i进行加1并赋回给i,完成后i的值变为2。好了,此时CPU又调度t1让其继续执行,重点在这里,还记不记得t1暂停前读取到的i是几?没错是1,此时t1执行第6行代码,对i进行加1得到的结果是2然后赋回给i。好了,问题出来了,我们清楚的直到循环进行了两次,按正常逻辑来说,对i进行两次加1操作后,此时i应该等于3,但是两条线程完成两次加1操作后i的值竟然是2,当进行第三次循环的时候,读取到i的值将会是2,这样的结果是不是很诡异,这就是线程安全问题的产生。那么引发这个问题的原因是什么呢?其实就是将读和写进行了分割,当读和写分割开后,如果一条线程读完但未写时被CPU停掉,此时其他线程就有可能趁虚而入导致最后产生奇怪的数据。
那么上面这段代码怎么修改才能不产生线程安全问题呢?我们知道一条线程被CPU调度执行任务时,最少要执行一行代码,所以解决办法很简单,只要将读和写合并到一起,即合并到一行就行了:
int i = 1;
while(i < 10){
i++;
};
这样,我们将读和写用i++来替代,此时线程无论在哪行停止,其他线程也不会对数据产生干扰,我画一个图来形象的说明这一点(图有点丑,不要介意):

我们可以把左边的圆看成是一行代码,右边的圆被分割成了两行代码。如果数据没有破坏原子性,由于线程被调度一次的最少要执行1行代码,那么t1只要执行了这行代码,就会连读带写全部完成,其他线程再拿到的数据就是被写过的最新数据,不会有任何安全隐患;而如果数据破坏了原子性,将读写进行了分割,那么t1,读取完数据如果停掉的话,t2执行的时候拿到的就是一个老数据(没有被更新的数据),接下来t1,t2同时对相同的老数据进行更新势必会因此数据的异常。
2.注意
对于线程安全问题,需要注意以下两点:
- 只存在读数据的时候,不会产生线程安全问题。
- 在java中,只有同时操作成员(全局)变量的时候才会产生线程安全问题,局部变量不会(每个线程执行时将会把局部变量放在各自栈帧的工作内存中,线程间不共享,故不存在线程安全问题,这里不展开描述内存问题,有兴趣可自行百度)。
3.代码演示
基于上面的分析,我们通过最经典的卖票的例子来进行代码演示。需求:使用两个线程来模拟两个窗口同时出售100张票:
public class TicketThread implements Runnable{
private int ticketCount = 100;
@Override
public void run() {
while (ticketCount > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
sale();
}
}
public void sale(){
if (ticketCount > 0) {
System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "张票");
ticketCount --;
}
}
}
public class Main {
public static void main(String[] args) {
TicketThread ticketThread = new TicketThread();
Thread t1 = new Thread(ticketThread, "窗口1--");
Thread t2 = new Thread(ticketThread, "窗口2--");
t1.start();
t2.start();
}
}
运行结果:

结果分析:
从结果来看出现了很多诡异的数据,很明显是发生了线程安全问题,根据上面的分析,相信你应该知道是哪里导致的了。正式由于TicketThread类中第 19,20行的代码对成员变量ticketCount的读和写进行了分割才造成的。至于线程安全问题的解决方法之一,通过synchronized关键字会在下面进行讲解。
二、使用synchronized解决线程安全问题
1.synchronized的概念
synchronized在英语中翻译成同步,同步想必大家都不陌生。例如同步调用,有A,B两个方法,必须要先调用A并且获得A的返回值才能去调用B,也就是说,想做下一步,必须要拿到上一步的返回值。同样的道理,使用了synchronized的代码,当线程t1进入的时候,另一个线程若t2想进入,就必须要得到返回值才能进入,怎么得到返回值呢?那就要等t1出来了才会有返回值。这就是多线程中常说的加锁,使用synchronized的代码我们可以想象成将他们放到了一个房间,我前边所说的返回值就相当于这个房间的钥匙,进入这个房间的线程同时会把钥匙带进去,当它出来的时候会将钥匙仍在地上(释放资源),然后其他线程过来抢钥匙(争夺CPU执行权),以此类推。
被放到房间里代码,其实就是为了让其保持原子性,因为当线程t1进入被synchronized修饰的代码当中的时候,其他线程是被锁在外边进不来的,知道线程t1执行完里边的所有代码(或抛出异常),才会释放资源。我们换个角度想,这不就是让房间(synchronized)里面的代码保持了原子性吗,某一线程只要进去了,就必须要执行完毕里边的代码别的线程再进去,期间不会有其他线程趁虚而入来干扰它,就像我上面图中左边那个圆一样,也就是相当于将本来分割的读和写的操作合并在了一起,让一个线程要么不执行,只要执行就得把读和写全部执行完(且期间不会受干扰)。
理解了我上边所说的,就再也不用纠结到底把什么代码放入synchronized中了,只要把读和写分割的代码,并且分割后会引发线程安全问题的代码放入让其保持原子性就可以了。很明显在上面TicketThread类中,就是第19和20行。
2.synchronized的三种用法
(1)同步代码块
public class SynchronizedBlockThread implements Runnable {
private Object obj = new Object();
private int ticketCount = 100;
@Override
public void run() {
while (ticketCount > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
sale();
}
}
public void sale(){
synchronized (obj) { //使用同步代码块使线程间同步
if (ticketCount > 0) {
System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "张票");
ticketCount --;
}
}
}
}
public class Main {
public static void main(String[] args) {
SynchronizedBlockThread blockThread = new SynchronizedBlockThread();
Thread t1 = new Thread(blockThread, "窗口1--");
Thread t2 = new Thread(blockThread, "窗口2--");
t1.start();
t2.start();
}
}
代码分析:
SynchronizedBlockThread类中需要注意一点,第20行,多个线程之间的同步代码块中必须使用相同的锁(体现在代码中就是同一个对象)才能保证同步,才能使其他不进入干扰,两条线程如果使用的不是同一把锁,那么一条线程进入synchronized中且未释放资源前,另一条线程依然可以进入。同步代码块中使用的锁要求必须是引用数据类型,最常用的就是传入一个Object对象,或者使用当前类的对象,即this。
运行结果:使用synchronized是读写数据同步后没有再出现线程安全问题

(2)同步函数
public class SynchronizedMethodThread implements Runnable{
private int ticketCount = 100;
@Override
public void run() {
while (ticketCount > 0) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
sale();
}
}
public synchronized void sale(){ //使用同步函数使线程间同步
if (ticketCount > 0) {
System.out.println(Thread.currentThread().getName()
+ "正在出售第" + (100-ticketCount+1) + "张票");
ticketCount --;
}
}
}
public class SellTicketMain {
public static void main(String[] args) {
SynchronizedMethodThread methodThread = new SynchronizedMethodThread();
Thread t1 = Thread t2 = }
}
代码分析:
在(1)同步代码块中,我们创建了Object对象并将其当做锁来使用,那么在同步函数中,我们无法自己传入锁,那是不是同步函数中有默认的锁呢?没错,同步函数中默认使用的锁是当前类的对象,即this。下面代码证明了同步函数中使用的锁是this:
public class VerifySynchronizedThread implements Runnable {
private static int trainCount = 100;
private Object obj = new Object();
public boolean flag = true;
@Override
public void run() {
if (flag) {
// 执行同步代码块this锁
while (trainCount > 0) {
synchronized (this) {
if (trainCount > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票");
trainCount--;
}
}
}
} else {
// 执行同步函数
while (trainCount > 0) {
sale();
}
}
}
public synchronized void sale() { // 同步函数
if (trainCount > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票");
trainCount--;
}
}
}
public class Main {
public static void main(String[] args) {
VerifySynchronizedThread thread = new VerifySynchronizedThread();
Thread t1 = new Thread(thread, "窗口1--");
Thread t2 = new Thread(thread, "窗口2--");
t1.start();
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.flag = false;
t2.start();
}
}
代码分析:
我们通过flag控制,让t1执行同步代码块,让t2执行同步函数,由于两条线程同时操作trainCount这个成员变量,因此可能会引发线程安全问题,按照我们前边的描述,使用synchronized让线程同步,但是现在t1使用的是同步代码块,t2使用的是同步函数,按照前边的分析如果他们俩使用的是通一把锁,那么当一个线程进入synchronized中的代码时,另一个线程是进不去的,从而解决线程安全问题。我们既然是在验证同步函数使用的是this锁,因此我们将同步代码块中也使用this,经过几次反复的运行,并没有发现数据错误,也就说明了同步函数使用的是this锁,为了更加准确,我们再将同步代码块中的锁换成obj试一下,发现换成obj后出现了错误数据,因此我们证明了同步函数使用的是this锁。
(3)静态同步函数
public class StaticSynchronizedThread implements Runnable {
private static int ticketCount = 100;
public boolean flag = true;
@Override
public void run() {
if (flag) {
while (ticketCount > 0) {
synchronized (StaticSynchronizedThread.class) { // 同步代码块
if (ticketCount > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票");
ticketCount--;
}
}
}
} else {
// 执行静态同步函数
while (ticketCount > 0) {
sale();
}
}
}
public static synchronized void sale() { //静态同步函数
if (ticketCount > 0) {
try {
Thread.sleep(50);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票");
ticketCount--;
}
}
}
public class Main {
2
public static void main(String[] args) {
StaticSynchronizedThread thread = new StaticSynchronizedThread();
Thread t1 = new Thread(thread, "窗口1--");
Thread t2 = new Thread(thread, "窗口2--");
t1.start();
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
thread.flag = false;
t2.start();
}
}
代码分析:
静态同步函数的形式也比较简单,仅仅是将同步函数写成静态的形式。但是需要注意的是,静态同步函数使用的锁不是this,它也不可能使用this,因为我们知道静态函数要先于对象加载,也就是说当静态同步函数被加载的时候,本类的对象即this在内存中还不存在,因此更不可能使用它。这里静态同步函数使用的锁其实是本类的字节码文件,即StaticSynchronizedThread.class。同样还使用之前的代码,将同步代码块的锁设为StaticSynchronizedThread.class来验证,运行发现不会出现错误数据,当换成其他锁时,便会出现错误数据。
3.对于synchronized的总结
- 要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。
- 要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。
- 使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。
java线程安全问题以及使用synchronized解决线程安全问题的几种方式的更多相关文章
- 解决线程安全问题_同步方法和解决线程安全问题_Lock锁
解决线程安全问题_同步方法 package com.yang.Test.ThreadStudy; import lombok.SneakyThrows; /** * 卖票案例出现了线程安全的问题 * ...
- 线程同步(使用了synchronized)和线程通讯(使用了wait,notify)
线程同步 什么是线程同步? 当使用多个线程来访问同一个数据时,非常容易出现线程安全问题(比如多个线程都在操作同一数据导致数据不一致),所以我们用同步机制来解决这些问题. 实现同步机制有两个方法:1.同 ...
- UI的线程问题:单线程原因及更新UI的四种方式
1.UI线程为什么设计为单线程? UI控件的操作不是线程安全的,对于多线程并发访问的时候,如果使用加锁机制会导致: UI控件的操作变得很复杂. 加锁的操作必定会导致效率下降. 所以android系统在 ...
- Java多线程(二)——常用的实现多线程的两种方式
一.继承Thread类创建线程类 Java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例.每个线程的作用是完成一定的任务,实际上就是执行一段程序流即一段顺序执行的代码. ...
- 浅谈Spring解决循环依赖的三种方式
引言:循环依赖就是N个类中循环嵌套引用,如果在日常开发中我们用new 对象的方式发生这种循环依赖的话程序会在运行时一直循环调用,直至内存溢出报错.下面说一下Spring是如果解决循环依赖的. 第一种: ...
- Java基础知识强化之IO流笔记62:三种方式实现键盘录入
1. 三种方式实现键盘录入 System.in 标准输入流.是从键盘获取数据的 键盘录入数据三种方式: A:main方法的args接收参数. java HelloWorld hello w ...
- 【转】java中byte数组与int类型的转换(两种方式)----不错
原文网址:http://blog.csdn.net/piaojun_pj/article/details/5903009 java中byte数组与int类型的转换,在网络编程中这个算法是最基本的算法, ...
- 解决ajax跨域几种方式
发生跨域问题的原因: 浏览器的限制,出于安全考虑.前台可以正常访问后台,浏览器多管闲事报跨域问题,但其实前台已经访问到后台了. 跨域,协议.域名.端口任何一个不一样浏览器就认为是跨域. XHR(XML ...
- java中获取类加载路径和项目根路径的5种方式分析
package my; import Java.io.File; import java.io.IOException; import java.net.URL; public class MyUrl ...
随机推荐
- Windows Server 2016-查询FSMO角色信息的三种方法
FSMO操作主机角色有五种:林范围操作主机角色有两种,分别是 架构主机角色(Schema Master)和 域命名主机角色(Domain Naming Master):及域范围操作主机角色三种,分别是 ...
- Vi/Vim的快捷方式
1 vi/ vim键盘图 2 文字解说 进入编辑模式的6种方式: i在光标前插入 I在行首插入 a在光标后插入 A在行末插入 o在下一行插入 O在上一行插入 删除字符 x 删除当前字符 X 删除前一个 ...
- 关于目前自己iOS项目使用的第三方开源库
1.AFNetworking 目前比较推荐的iOS网络请求组件,默认网络请求是异步,通过block回调的方式对返回数据进行处理. 2.FMDB 对sqlite数据库操作进行了封装,demo也比较简单. ...
- fiddler 抓取 nodejs
nodejs 代理指向 fiddler var options = { ........ }; options.path = 'http://' + options.host + ...
- href与src 区别
src 是可替换的文本支撑,将指向的内容引入文档当前标签所在的位置, 当浏览器解析到该标签时,将暂停其它资源的下载处理, 请求该标签的src ,下载指向的外部资源并应用到当前文档, 所以js 脚本一般 ...
- .NET常用第三方库(包)总结
文章会不定期更新,以下内容均为个人总结,欢迎各位拍砖指正 序列化与反序列化 JSON.NET应该是.NET平台上使用最为广泛的序列化/反序列化包了,ASP.NET和ASP.NET Core中默认序列化 ...
- 使用JSONP进行跨域Ajax 调用
JSONP 是啥 JSONP 全称是JSON with Padding. 当需要进行跨域Ajax 调用的时候, 需要用到JSONP 协议. 客户端 $.ajax({ url: 'http://xxx' ...
- the c programing language 学习过程7
interact 互动 carriage运费运输 linefeed 换行 redirection改方向 interleaved交叉存取 adequate足够的 untouched原样的 specif ...
- uva 1151最小生成树
先求一次最小生成树,可以排除n*(n*1)/2-(n-1)条边,每次利用二进制法枚举套餐的选择,套餐中的点直接处理,如果两个套餐有公共点直接合并,他们一定连通,然后枚举第一步最小生成树得到的n-1条边 ...
- web前端研发工程师编程能力成长之路
[背景] 如果你是刚进入WEB前端研发领域,想试试这潭水有多深,看这篇文章吧:如果你是做了两三年WEB产品前端研发,迷茫找不着提高之路,看这篇文章吧:如果你是四五年的前端开发高手,没有难题能难得住你的 ...