首页 > 技术文章 > 关于多线程并发环境下的数据安全的问题

Leeyoung888 2020-10-18 16:12 原文

1.为什么是重点?

  以后在开发中,项目都是运行在服务器当中,而服务器已经将线程的定义、线程对象的创建、线程的启动等,都已经实现完了。这些代码都不需要编写,最重要的是要知道:编写的程序需要放到一个多线程的环境下运行,更需要关注这些数据在多线程并发的环境下是否是安全的。

2.什么时候数据在多线程并发的环境下会存在安全问题?

  三个条件:

  (1)条件1:多线程并发

  (2)条件2:多线程有共享的数据

  (3)条件3:共享的数据有修改的行为

  满足以上3个条件之后,就会存在线程安全问题。

3.如何解决线程安全问题?

  当多线程并发的环境下,有共享数据,并且这个数据还会被修改,此时就存在线程安全问题。

  此时应当使线程排队执行(不能并发)。用排队执行解决线程安全问题,这种机制称为:线程同步机制。(这是专业术语的叫法,实际上就是线程不能并发了,必须排队执行)。线程同步(也就是线程排队)会牺牲一部分效率,但是数据安全是第一位的。

4.线程同步涉及到的两个专业术语

  4.1 异步编程模型

    线程t1和t2各自执行各自的,相互不管,谁也不需要等谁。(其实就是多线程并发,效率较高)

  4.2 同步编程模型

    线程t1和t2,在某一线程执行的时候,另一个线程必须等待正在执行的线程,一直到其执行完毕为止。两个线程之间发生了等待关系,线程排队执行,效率较低。

5.例子分析

  

 1 package thread_safe;
 2 
 3 public class Account {
 4     
 5     //账户
 6     private String sctno;
 7     
 8     //余额
 9     private double balance;
10 
11     public Account() {
12     
13     }
14 
15     public Account(String sctno, double balance) {
16         this.sctno = sctno;
17         this.balance = balance;
18     }
19 
20     public String getSctno() {
21         return sctno;
22     }
23 
24     public void setSctno(String sctno) {
25         this.sctno = sctno;
26     }
27 
28     public double getBalance() {
29         return balance;
30     }
31 
32     public void setBalance(double balance) {
33         this.balance = balance;
34     }
35     
36     //取款方法
37     public void withdraw(double money){
38         //t1和t2并发这个方法;t1、t2是两个栈。两个栈操作堆中同一个对象
39         //取款前的余额
40         double before=this.getBalance();
41         double after=before-money;//取款后的余额
42         //更新余额
43         //若t1执行到这里,但还没来得及执行第44行代码,t2线程进来withdraw()方法了,此时一定出现问题。
44         this.setBalance(after);
45         
46     }
47 
48 }
 1 package thread_safe;
 2 
 3 public class AccountThread extends Thread {
 4     
 5     //两个线程必须共享同一个账户对象
 6     private Account act;
 7     
 8     //通过构造方法传递过来账户对象
 9     public AccountThread(Account act){
10         this.act=act;
11     }
12     public void run(){
13         //run()方法执行取款操作
14         //假设取款5000
15         double money=5000;
16         //取款
17         act.withdraw(money);
18         System.out.println(Thread.currentThread().getName()+"对账户"+act.getSctno()+"取款成功,余额:"+act.getBalance());
19     }
20 }
 1 package thread_safe;
 2 
 3 public class Test {
 4     public static void main(String[] args){
 5         
 6         //创建一个账户对象
 7         Account act=new Account("act-001",10000);
 8         
 9         //创建两个线程
10         Thread t1=new AccountThread(act);
11         Thread t2=new AccountThread(act);
12         
13         //设置名字
14         t1.setName("t1");
15         t2.setName("t2");
16         
17         //启动线程取款
18         t1.start();
19         t2.start();
20         
21     }
22 
23 }

运行结果:

  可以看到,两个余额都是5000,这就说明出现了问题。但是要注意但是,出现问题是个概率事件,即这种情况可能发生,也可能不发生,关键在于当某个线程即将执行 this.setBalance(after);这行代码的时候,另一个线程是否已经执行withdraw()方法了,倘若另一个线程已经执行了withdraw()方法,那么即将执行this.setBalance(after) 这行代码的线程执行完这行代码后,肯定会出现问题。

  为了放大这个问题,可以在this.setBalance(after)之前设置一个延时,这样一定出错,问题显示的也更加明显:

 1 package thread_safe;
 2 
 3 public class Account {
 4     
 5     //账户
 6     private String sctno;
 7     
 8     //余额
 9     private double balance;
10 
11     public Account() {
12     
13     }
14 
15     public Account(String sctno, double balance) {
16         this.sctno = sctno;
17         this.balance = balance;
18     }
19 
20     public String getSctno() {
21         return sctno;
22     }
23 
24     public void setSctno(String sctno) {
25         this.sctno = sctno;
26     }
27 
28     public double getBalance() {
29         return balance;
30     }
31 
32     public void setBalance(double balance) {
33         this.balance = balance;
34     }
35     
36     //取款方法
37     public void withdraw(double money){
38         //t1和t2并发这个方法;t1、t2是两个栈。两个栈操作堆中同一个对象
39         //取款前的余额
40         double before=this.getBalance();
41         double after=before-money;//取款后的余额
42         
43         //进行1秒的睡眠,模拟网络延时
44         try {
45             Thread.sleep(1000);
46         } catch (InterruptedException e) {
47             // TODO Auto-generated catch block
48             e.printStackTrace();
49         }
50         //更新余额
51         //若t1执行到这里,但还没来得及执行第44行代码,t2线程进来withdraw()方法了,此时一定出现问题。
52         this.setBalance(after);
53         
54     }
55 
56 }

  这样之前所说的概率事件就成了一个肯定发生的事件,即每次运行都会出现问题。

 解决方法:

 1 public void withdraw(double money){
 2         
 3         //一下几行代码必须是线程排队的,不能并发
 4         //一个线程将这里的代码全部执行完毕后,另一个代码才能进来
 5         /*
 6          * 线程同步机制的语法是:
 7          *  synchronized(){
 8          *      线程同步代码块
 9          *  
10          *  }
11          *  
12          *    synchronized后面小括号中传的这个数据是相当重要的,这个数据必须是多线程共享的数据,才能达到多线程排队
13          *    ()中写的是想让同步的线程,这里让t1,t2两个线程同步。
14          *  
15          *    
16          *    这里的共享对象是:账户对象 。账户对象是共享的,而这里的this就是账户对象
17          *    
18          *    在java中,任何一个对象都有“一把锁”,其实这把锁就是一个标记(100个对象100把锁)
19          *    
20          *    下面代码 的原理:
21          *     1.假设t1和t2线程并发,开始执行一下代码的时候,肯定有一个先一个后
22          *     2.假设t1先执行了,遇到synchronized,这个时候自动找后面“共享对象”(也就是这里的Account)的对象锁。找到之后并占有这把锁,
23          *  然后执行同步代码块中的程序,在程序执行过程中一直占有这把锁,直到同步代码块代码结束,这把锁才会释放。
24          *     3. 当t2想占有对象的锁的时候,发现t1已经占有,所以只能在同步代码块外等候t1执行完同步代码块里的程序,归还对象锁后,t2再占用对象的锁,
25          *  然后进入同步代码块执行程序
26          *  
27          *     注:共享对象的选择一定要选好,这个对象一定要是需要排队执行的这些线程对象所共享的
28         */
29         synchronized(this){
30         double before=this.getBalance();
31         double after=before-money;//取款后的余额
32         
33         try {
34             Thread.sleep(1000);
35         } catch (InterruptedException e) {
36             
37             e.printStackTrace();
38         }
39         this.setBalance(after);
40         }
41         
42     }

运行结果:

思考:

 a.  在Account对象中再添加一个Obj对象(全局对象)

  

 1 public class Account {
 2     
 3     //账户
 4     private String sctno;
 5     
 6     //余额
 7     private double balance;
 8     
 9     
10     //对象
11     Object obj=new Object(); //实例变量,(Account)对象是多线程共享的,所以Account对象中的实例变量obj也是被这些线程共享的

  然后在synchronized()的括号内放入obj对象

 1 synchronized(obj){
 2         double before=this.getBalance();
 3         double after=before-money;//取款后的余额
 4         
 5         try {
 6             Thread.sleep(1000);
 7         } catch (InterruptedException e) {
 8             
 9             e.printStackTrace();
10         }
11         this.setBalance(after);
12         }
13         
14     }

运行结果:

  b.在withdraw()方法里添加一个对象(局部对象)。

 1 package Thread_safe2;
 2 
 3 public class Account {
 4     
 5     //账户
 6     private String sctno;
 7     
 8     //余额
 9     private double balance;
10     
11     
12     //全局对象 obj
13     Object obj=new Object(); //实例变量,(Account)对象是多线程共享的,所以Account对象中的实例变量obj也是被这些线程共享的
14 
15     public Account() {
16     
17     }
18 
19     public Account(String sctno, double balance) {
20         this.sctno = sctno;
21         this.balance = balance;
22     }
23 
24     public String getSctno() {
25         return sctno;
26     }
27 
28     public void setSctno(String sctno) {
29         this.sctno = sctno;
30     }
31 
32     public double getBalance() {
33         return balance;
34     }
35 
36     public void setBalance(double balance) {
37         this.balance = balance;
38     }
39     
40     //取款方法
41     public void withdraw(double money){
42         //局部对象 obj2
43         Object obj2=new Object();
44         synchronized(obj2){
45         double before=this.getBalance();
46         double after=before-money;//取款后的余额
47         
48         try {
49             Thread.sleep(1000);
50         } catch (InterruptedException e) {
51             
52             e.printStackTrace();
53         }
54         this.setBalance(after);
55         }
56         
57     }
58 
59 }

运行结果:

 

分析原因 :obj2是局部变量,所以它不是共享对象

  c.在withdraw()方法里添加字符串“abc”

synchronized("abc"){
        double before=this.getBalance();
        double after=before-money;//取款后的余额
        
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            
            e.printStackTrace();
        }
        this.setBalance(after);
        }

  这种方法也行,因为“abc”在字符串常量池中,但是需要注意的是,写“abc”是所有线程都同步,也就是说,如果这里再有几个线程t3、t4。。。。。。,它们也会同步,如果只需要t1和t2同步,其他线程不希望同步,那么就不能采用这样写法。如:

 1 package Thread_safe2;
 2 
 3 public class Test {
 4     public static void main(String[] args){
 5         
 6         //创建一个账户对象
 7         Account act=new Account("act-001",10000);
 8         Account act1=new Account("act-002",10000000);
 9         
10         //创建两个线程
11         Thread t1=new AccountThread(act);
12         Thread t2=new AccountThread(act);
13         
14         //额外创建一个线程
15         Thread t3=new AccountThread(act1);
16         
17         //设置名字
18         t1.setName("t1");
19         t2.setName("t2");
20         
21         t3.setName("t3");
22         
23         //启动线程取款
24         t1.start();
25         t2.start();
26         t3.start();
27         
28     }
29 
30 }

  (1)synchronized()中写this,这种情况下,只有t1与t2同步,也就是说,它们需要排队,后执行者等前面的归还对象锁(t1与t2共享的对象是act,所以它们两个要排队使用act的对象锁)后,才执行,而此时t3的对象是act1,括号中的this指的是当前对象,所以此时,t3与t1和t2都不同步。

  (2)synchronized()中写“abc”,这种情况下,只有t1、t2、t3都同步,它们共同使用对象“abc”的锁,所以需要排队。

6.java中的三大变量

  实例变量:存在堆中

  静态变量:存在方法区中

  局部变量:存在栈中

  以上三大变量中,局部变量永远不会存在线程安全问题,因为局部变量在栈中,而一个线程一个栈,所以局部变量永远不会被共享所以也就不会存在安全问题了。

  实例变量在堆中,静态变量在方法区中,而堆和方法区都只有一个,所以会堆和方法区都是多线程共享的,所以可能存在安全问题。

  综上所述:局部变量和常量(因为常量不可修改)都没有线程安全问题,而成员变量(实例变量+静态变量)会存在线程安全问题

7. 如果使用局部变量的话,使用StringBuilder还是StringBuffer?

  建议使用:StringBuilder。

  虽然StringBuffer是线程安全的,StringBuilder是非线程安全的,然而将它们用作局部变量的时候,因为局部变量不存在线程安全问题,StringBuffer因为是线程安全的,反而效率会降低(因为StringBuffer的方法上都加synchronized了,每次运行到这里的时候都会去“锁池”里走一趟,所以会降低效率),所以这个时候应该使用StringBuilder。

  非线程安全:ArrayList、HashMap、HashSet

  线程安全:Vector、Hashtable

8. 总结

  synchronized有两种写法

    第一种:同步代码块。

      synchronized(线程共享对象){

        同步代码块  

      }

 

    第二种:在实例方法上使用synchronized。表示共享对象一定是this,并且同步代码块 是整个方法体

 

    第三种:在静态方法上,使用synchronized。表示使用类锁,类锁永远只有1把,就算创建了100个对象,那类锁也只有一把。

 

               对象锁:一个对象一把锁,100个对象100把锁

     类锁:100个对象,也可能只是一把锁

   4个例子:

  a.

 1 package exam;
 2 
 3 /*
 4  * doOther()的执行是否需要等doSome()结束?
 5  * 不需要,因为doOther()不需要占用this的锁,即当doSome()占用this锁执行的时候,不影响doOther()的执行
 6  */
 7 public class Exam01 {
 8     
 9     public static void main(String[] args) throws Exception{
10         
11         MyClass mc=new MyClass();
12         Thread t1=new MyThread(mc);
13         Thread t2=new MyThread(mc);
14         
15         t1.setName("t1");
16         t2.setName("t2");
17         
18         t1.start();
19         //睡眠的作用是保证t1先执行
20         Thread.sleep(1000);
21         t2.start();
22     }
23 
24 }
25 class MyThread extends Thread{
26     MyClass mc=new MyClass();
27     public MyThread(MyClass mc){
28         this.mc =mc;
29     }
30     public void run(){
31         if(Thread.currentThread().getName().equals("t1")){
32             mc.doSome();
33         }
34         if(Thread.currentThread().getName().equals("t2")){
35             mc.doOther();
36         }
37     }
38 }
39 class MyClass{
40     public synchronized void doSome(){
41         System.out.println("doSome begin");
42         
43         try {
44             Thread.sleep(1000*10);
45         } catch (InterruptedException e) {
46             // TODO Auto-generated catch block
47             e.printStackTrace();
48         }
49         System.out.println("doSome over");
50     }
51     
52     public void doOther(){
53         System.out.println("doOther begin");
54         System.out.println("doOther over");
55     }
56 }

  b.

 1 package exam;
 2 
 3 
 4 /*
 5  * doOther()的执行是否需要等doSome()结束?
 6  * 需要,因为等doSome()执行结束了,才会释放this的锁,然后doOther()拿到this的锁了,才能继续执行
 7  */
 8 public class Exam2 {
 9 public static void main(String[] args) throws Exception{
10         
11         MyClass1 mc=new MyClass1();
12         Thread t1=new MyThread1(mc);
13         Thread t2=new MyThread1(mc);
14         
15         t1.setName("t1");
16         t2.setName("t2");
17         
18         t1.start();
19         //睡眠的作用是保证t1先执行
20         Thread.sleep(1000);
21         t2.start();
22     }
23 
24 }
25 class MyThread1 extends Thread{
26     MyClass1 mc=new MyClass1();
27     public MyThread1(MyClass1 mc){
28         this.mc =mc;
29     }
30     public void run(){
31         if(Thread.currentThread().getName().equals("t1")){
32             mc.doSome();
33         }
34         if(Thread.currentThread().getName().equals("t2")){
35             mc.doOther();
36         }
37     }
38 }
39 class MyClass1{
40     public synchronized void doSome(){
41         System.out.println("doSome begin");
42         
43         try {
44             Thread.sleep(1000*10);
45         } catch (InterruptedException e) {
46             // TODO Auto-generated catch block
47             e.printStackTrace();
48         }
49         System.out.println("doSome over");
50     }
51     
52     public synchronized void doOther(){
53         System.out.println("doOther begin");
54         System.out.println("doOther over");
55     }
56 }

  c.

 1 package exam;
 2 
 3 /*
 4  * doOther()的执行是否需要等doSome()结束?
 5  * 不需要,因为MyClass对象是两个,两把锁
 6  */
 7 
 8 public class Exam3 {
 9 public static void main(String[] args) throws Exception{
10         
11         MyClass2 mc1=new MyClass2();
12         MyClass2 mc2=new MyClass2();
13         Thread t1=new MyThread2(mc1);
14         Thread t2=new MyThread2(mc2);
15         
16         t1.setName("t1");
17         t2.setName("t2");
18         
19         t1.start();
20         //睡眠的作用是保证t1先执行
21         Thread.sleep(1000);
22         t2.start();
23     }
24 
25 }
26 class MyThread2 extends Thread{
27     MyClass2 mc=new MyClass2();
28     public MyThread2(MyClass2 mc){
29         this.mc =mc;
30     }
31     public void run(){
32         if(Thread.currentThread().getName().equals("t1")){
33             mc.doSome();
34         }
35         if(Thread.currentThread().getName().equals("t2")){
36             mc.doOther();
37         }
38     }
39 }
40 class MyClass2{
41     public synchronized void doSome(){
42         System.out.println("doSome begin");
43         
44         try {
45             Thread.sleep(1000*10);
46         } catch (InterruptedException e) {
47             // TODO Auto-generated catch block
48             e.printStackTrace();
49         }
50         System.out.println("doSome over");
51     }
52     
53     public synchronized void doOther(){
54         System.out.println("doOther begin");
55         System.out.println("doOther over");
56     }
57 
58 }

  d.

 1 package exam;
 2 /*
 3  * doOther()的执行是否需要等doSome()结束?
 4  * 需要,因为静态方法是类锁,不管创建了几个对象,类锁只有1把
 5  */
 6 public class Exam4 {
 7 public static void main(String[] args) throws Exception{
 8         
 9         MyClass3 mc1=new MyClass3();
10         MyClass3 mc2=new MyClass3();
11         Thread t1=new MyThread3(mc1);
12         Thread t2=new MyThread3(mc2);
13         
14         t1.setName("t1");
15         t2.setName("t2");
16         
17         t1.start();
18         //睡眠的作用是保证t1先执行
19         Thread.sleep(1000);
20         t2.start();
21     }
22 
23 }
24 class MyThread3 extends Thread{
25     MyClass3 mc=new MyClass3();
26     public MyThread3(MyClass3 mc){
27         this.mc =mc;
28     }
29     public void run(){
30         if(Thread.currentThread().getName().equals("t1")){
31             mc.doSome();
32         }
33         if(Thread.currentThread().getName().equals("t2")){
34             mc.doOther();
35         }
36     }
37 }
38 class MyClass3{
39     // synchronized 出现在静态方法上找的是类锁
40     public synchronized static void doSome(){
41         System.out.println("doSome begin");
42         
43         try {
44             Thread.sleep(1000*10);
45         } catch (InterruptedException e) {
46             // TODO Auto-generated catch block
47             e.printStackTrace();
48         }
49         System.out.println("doSome over");
50     }
51     
52     public synchronized static void doOther(){
53         System.out.println("doOther begin");
54         System.out.println("doOther over");
55     }
56 
57     
58 
59 }

 9.开发中如何解决线程安全问题?

  虽然使用synchronized()会解决线程安全问题,保证线程的同步,但是这种方法也有缺点:会降低用户的吞吐量(并发量),使得程序执行效率降低,所以尽量避免使用这种方法。

  方法一:尽量使用局部变量代替实例变量和静态变量。

  方法二:如果必须使用实例变量,可以考虑创建多个对象,这样就可以避免共享实例变量的内存。(1个线程对应1个对象,100个线程对应100个对象,若线程之间不存在共享对象,也就不会出现安全问题)。

  方法三:如果不能使用局部变量,对象也不能创建多个,这个时候就只能使用synohronized.

10. 线程部分的其他内容

  10.1 守护线程

    java语言中,线程分为两类,一类叫用户线程,另一类叫守护线程(后台线程)。

    守护线程的特点:一般是个死循环;只要用户线程结束,守护线程就会自动结束。垃圾回收线程就是一个守护线程,main方法就是一个用户线程。

    守护线程用在什么地方?

  10.2 定时器

  10.3 实现线程的第三种方式:FutureTask方式,实现Callable接口。(JDK8新特性)

  10.4 关于Object类中的wait和notify方法。(生产者和消费者模式)

推荐阅读