线程安全

本文深入探讨了线程安全的概念,通过实际例子展示了非线程安全可能导致的问题,例如工资管理、投票功能和Stack操作。解决这些问题通常需要使用线程同步,如Java中的`synchronized`关键字。文章通过不同示例分析了数据共享和不共享情况下的线程安全问题,并提供了同步策略以确保线程安全执行。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1.线程安全概述

使用多线程可以在一段时间内并发处理多个任务,在提高CPU运行效率的同时也为我们批量处理这些任务带来了便利。但是,使用多线程的时候要格外小心,多个线程在某一时间对同一个变量的处理,如果处理不当,就会造成数据不一致的问题,出现的这种数据不一致的现象就是非线程安全。非线程安全是多线程才会出现的问题。

上面的情况只是非线程安全的一种。非线程安全出现的原因是各个线程的控制流彼此独立,线程的执行需要线程调度程序管理,他们之间的执行顺序是随机的不确定的,而各个线程共享资源,所以多线程会带来线程调度,同步,死锁等一系列的问题。产生非线程安全问题的原因在于对共享数据访问操作的不完整性。

非线程安全针对的是共享数据的情况。共享数据如全局变量,这些变量对多个线程来说可以同时访问,同时访问的过程中就会有出现问题的可能。私有数据如方法内部的实例变量,这种变量的作用域只在方法本身,出了方法体就无法访问。对于私有数据,多线程同时访问就不存在线程安全的问题,所以线程执行的结果总是线程安全的,这是方法内部的变量是私有的特性造成的。

2.和线程安全有关的实际例子

(1)一个工资管理人员正在修改雇员的工资表,而一些雇员同时正在领取工资,如果允许这样做,必然会引起工资发放的混乱。

(2)实现投票功能时,多个线程可以同时处理同一个人的票数。

(3)两个线程A和B在同时使用Stack的同一个实例对象。A正在往堆栈里push一个数据,B则要从堆栈中pop一个数据。

class Stack{
         int idx=0;
         char[ ] data = new char[6];
         public void push(char c){
               data[idx] = c;
               idx++;
         }
         public char pop(){
               idx--;
               return data[idx];
         }
  }

(1)操作之前,栈中的数据如图所示,此时idx = 2。

data 
 
 
 
 
          q     
          p

(2)A执行push中的第一个语句,将r推入堆栈,idx = 2;

data
 
 
 
          r
          q
          p

(3)A还未执行idx++语句,A的执行被B中断,B执行pop( )方法,返回q,idx = 1,此时堆栈中数据同(2);

A继续执行push的第二条语句,idx = 2;最后的结果相当于q没有出栈,r也没有入栈。此时堆栈中数据同(2)。

要解决类似的这些非线程安全问题,需要使用线程同步,那么怎么样实现线程安全呢?通常使用synchronized关键字。

3.和线程安全有关的简单实例

从上面的描述得知,自定义类中的实例变量针对其他线程有共享和不共享之分,这在多线程之间进行交互时是很重要的一个技术点。线程的数据不共享就是线程中的数据只有自己能访问,别的线程不能访问。非线程安全只是针对共享数据,私有数据不存在这个问题。

(1)下面通过一个实例看下数据不共享的情况:

创建一个线程类,类中定义了一个私有变量count。

public class MyThread extends Thread {
	
	    private int count = 5;
	    public MyThread(String name){
	    super();
	    this.setName(name);
	    }
	    @Override
            public void run(){
		super.run();
		while(count>0){
		count--;
		System.out.println("由"+this.currentThread().getName()+"计算,count="+count);
		}        
	    }	
		
}

在main( )方法中创建三个线程的实例并运行。

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyThread a = new MyThread("A");
		MyThread b = new MyThread("B");
		MyThread c = new MyThread("C");
		a.start();
		b.start();
		c.start();
	}
	
}

程序的执行结果:

因为count变量是每个线程私有的,每个线程都有各自的线程变量。虽然线程是异步执行的,线程之间的执行顺序不确定,但是单看一个线程,每次执行他们的count值都是从4递减到0。在运行的时候每个线程自己减少自己count变量的值,一个线程的执行不会对其他线程的变量值有影响。这种情况就是变量不共享,此示例中并不存在多个线程访问同一个实例变量的情况。如果一个线程多次执行之后的结果都是一样的,那么这个线程就是线程安全的。

如果是3个线程同时对同一个实例变量进行减法操作,那就属于数据共享的情况。

(2)下面通过一个实例看下数据共享的情况:

和上面的类似,创建一个线程类,类中定义了一个私有变量count。

public class MyThread extends Thread {
	
	    private int count = 5;
	    @Override
            public void run(){
		super.run();
		//while(count>0){
		//此示例不要使用for或while循环语句,使用循环语句后首先获得机会运行的线程会同步运行,
                //同步后其他线程就得不到运行的机会了,一直由这个线程进行减法运算
		count--;
		System.out.println("由"+this.currentThread().getName()+"计算,count="+count);
		//}        
	    }	
		
}

 在main( )方法中创建一个MyThread线程,这个线程被其他的线程调用执行。

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		MyThread myThread = new MyThread();
		Thread a = new Thread(myThread,"A");
		Thread b = new Thread(myThread,"B");
		Thread c = new Thread(myThread,"C");
		Thread d = new Thread(myThread,"D");
		Thread e = new Thread(myThread,"E");
		a.start();
		b.start();
		c.start();
		d.start();
		e.start();
	}
	
}

	 
	

程序运行结果:

从线程的运行结果我们可以看出线程是异步执行的,线程的执行顺序和他们的书写顺序没有直接关系。“B”和“C”计算出来的结果都是3,说明 “B”和“C”同时对count进行处理,产生了“非线程安全”的问题。非线程安全就是运行的程序是固定的,但是程序每一次对变量值的修改结果却是不确定的。我们想要打印的结果是不重复的,依次递减的,这种结果也是执行线程安全的程序出现的结果,每一次对变量值的修改结果唯一。但是非线程安全运行的结果不唯一,线程安全执行的唯一的结果只是非线程安全运行结果的一种。

对比上面的例子,都是对MyThread类中的count变量进行操作,count变量都是线程私有的,运行结果却不同,这是为什么呢?

在上个实例中,main( )方法中创建了三个不同的MyThread实例,每个MyThread实例拥有一个count变量,因此这三个线程实例就有三个count变量,那么这些线程对各自count变量的操作是互不影响的。在本实例中,main( )方法中值创建了一个MyThread线程实例,那么就只有一个count变量。然后创建了几个不同的线程,把MyThread实例作为构造参数用来实例化这些线程。这些线程的其中一个构造参数相同,都是MyThread实例。那么这些线程调用start( )方法,最终线程调度程序执行的run( )方法都是构造参数中的同一个MyThread线程实例对象的run( )方法,相当于多个对象操作同一个变量。而上个实例是每个线程都操作的是自己私有变量。上个实例的变量私有,这个实例的变量共享。这是他们之间的区别,同时也验证了只有共享变量才会有非线程安全问题,私有变量不存在这个问题。

这里注意到:在MyThread的run( )方法中执行了一个i--的操作。i--是一个非原子性操作,也就是非线程安全的。原子性操作是不可划分的操作,那么即便是所有线程都能访问到,也都是线程安全的。非原子性操作的一行语句也可以划分为几个步骤。虽然看上去是一行语句,但是在某些JVM中,i--的操作要分为3个步骤:

<1>取得原有i值。

<2>计算i-1。

<3>对i赋值。

在这三个步骤中,如果有多个线程同时访问,那么一定会出现非线程安全问题。

那么出现“B”和“C”计算出来的结果都是3的原因,我们可以分析一下。

假如在第2步计算值的时候,另一个线程也在修改i的值,那么这个时候就会出现脏数据。

假设“B”,“C”没有运行之前count的值是5,执行i--分三个步骤,这时“B”和“C”首先执行前两步,对i进行了两次减1的操作,然后接着“执行第三步,此时“B”和“C”读出来的count变量值都是减了两个1之后的,就会出现读取的数据是“脏数据”的现象,其他运行结果的分析和“B”,“C”运行分析的思路类似。

这个例子的一个实例就是典型的销售场景。5个售货员,每个售货员卖出一个货品后不可以得出相同的剩余数量,必须在一个售货员卖出一个货品后其他售货员才可以在新的剩余物品上继续做减1操作。

要解决非线程安全问题,保证线程安全就需要在多个线程之间进行同步。 所谓同步就是在一段时间内只有一个线程运行,其他的线程必须等到这个线程运行结束之后才能继续执行。线程安全就是获得实例变量的值是经过同步处理的,不会出现“脏读”的现象。脏读”就是在读取实例变量时,该变量的值已经被其他线程更改过了。

为了避免了非线程安全的问题,实现线程同步,Java引入了对象互斥锁的概念,来保证数据共享操作的完整性。Java中每个对象都对应一个称为“互斥锁”的标记。

两个对象访问同一个对象的同步方法时一定是线程安全的。同步方法通常就是被synchronized关键字修饰的方法。

针对这个问题的线程同步策略就是按顺序排队的方式进行减1操作。可以在线程的run( )方法前加一个synchronized关键字进行同步。关键字synchrnized与对象互斥锁联合起来使用保证对象在任意时刻只能由一个线程访问。

public class MyThread extends Thread {
	
	    private int count = 5;
	    @Override
            synchronized public void run(){
		super.run();
		//while(count>0){
		//此示例不要使用for或while循环语句,使用循环语句后首先获得机会运行的线程会同步运行,同步后其他线程就得不到运行的机会了,一直由这个线程进行减法运算
		  count--;
		  System.out.println("由"+this.currentThread().getName()+"计算,count="+count);
		//}        
	    }	
		
}

在run( )方法前面添加了synchronized关键字之后程序运行的结果:

从运行结果可以看出,虽然线程之间的执行顺序是乱序的,但每次对count值的修改却都是依次递减的,这样就保证了线程安全。

通过在run( )方法前加synchrionized关键字,使多个线程执行run( )方法时以排队形式进行处理,通过synchrionized关键字修饰的方法就可以在该方法执行的时候给对象上锁。比如一个被synchrionized关键字修饰的run( )方法,在一个线程调用run( )方法前,要先判断run( )方法有没有被上锁,如果上锁,说明有其他线程在调用run( )方法,必须等待run( )方法执行完毕才能再有机会抢占run( )方法的锁执行。这样也就实现排队调用run( )方法的目的,也就达到按顺序对count变量进行减1操作的效果。synchrionized可以在任意对象及方法上加锁,加锁的这段代码成为“互斥区”或“临界区”。

当一个线程想要执行同步方法里面的代码时,线程首先尝试去拿这把锁,如果能拿到这把锁,那么这个线程就可以执行synchrionized里面的代码。如果不能拿到这把锁,那么这个线程就会不断的尝试拿这把锁,直到能够拿到为止,而且是有多个线程同时去争抢这把锁。关于synchrionized关键字的更多用法和线程同步更多的内容,参见:https://blog.youkuaiyun.com/kongmin_123/article/details/81301317

(3)非线程安全主要是指多个线程对同一个对象中的同一个实例变量进行操作时会出现值被更改,值不同步的情况,进而影响程序的执行流程。下面再来看一个非线程安全的实例。

创建一个LoginServlet类实现一个非线程安全的环境。

//本类模拟成一个Servlet组件
public class LoginServlet {
    private static String usernameRef;
    private static String passwordRef;
    public static void doPost(String username,String password){
    	try {
    	    usernameRef = username;   	
    	    if(username.equals("a")){    		
		Thread.sleep(5000);			
    	    }
    	    passwordRef = password;
    	    System.out.println("username="+usernameRef+" password="+passwordRef);
         } catch (InterruptedException e) {
		// TODO Auto-generated catch block
		e.printStackTrace();
	 }
    }
	
}
public class ALogin extends Thread {
       @Override
       public void run(){
    	   LoginServlet.doPost("a", "aa");
       }
}
public class BLogin extends Thread {
    @Override
    public void run(){
 	   LoginServlet.doPost("b", "bb");
    }
}

main( )方法中调用使用了LoginServlet的两个线程: 

public class Test {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		ALogin a = new ALogin();
		a.start();
		BLogin b = new BLogin();
		b.start();
	}
	
}

程序运行结果:

可以看出运行结果不确定, 执行结果是非线程安全的。

出现第一种运行情况的原因分析:A和B同时进入doPost( )方法,B首先给username赋值,A再给username赋值,导致B赋给username的值被A覆盖。A执行到username的判断语句睡眠,B首先执行,给password赋予了正确的值,最后先执行完毕返回“a”和“bb”,A睡眠结束接着执行返回“a”和“aa”。

出现第二种运行情况的原因分析:A和B同时进入doPost( )方法,A首先给username赋值,B再给username赋值,导致A赋给username的值被B覆盖。A执行到username的判断语句睡眠,B首先执行,给password赋予了正确的值,最后先执行完毕返回“b”和“bb”,A睡眠结束接着执行返回“b”和“aa”。

解决这个非线程安全问题的方法也是使用synchronized关键字。在LoginServlet类中的doPost( )方法前加上synchronized关键字,修改后的执行结果如下:

使用synchronized关键字之后,执行doPost( )方法时就排队进入方法,哪个线程进入该方法还没执行完的时候别的线程不能进入,只能排队等候,从而实现了线程的顺序执行,保证了多线程执行的安全性。

4.总结

如果多个线程共同访问对象中的一个实例变量,则有可能出现非线程安全问题:有可能出现实例变量的值被覆盖的情况,这在对MyThread类的访问中可以体现;如果多个线程共同访问的对象中有多个实例变量,则运行的结果有可能出现交叉的情况,可以对LoginServlet类的访问中可以体现。

单例模式中的实例变量也呈非线程安全状态。关于单例模式与线程安全,可以参考单例模式与多线程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值