一文教会你什么线程安全以及如何实现线程安全

1,037 阅读12分钟

一、线程安全的概念

线程安全是多线程编程是的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且准确的执行,不会出现数据污染等意外情况。上述是百度百科给出的一个概念解释。换言之,线程安全就是某个函数在并发环境中调用时,能够处理好多个线程之间的共享变量,是程序能够正确执行完毕。也就是说我们想要确保在多线程访问的时候,我们的程序还能够按照我们的预期的行为去执行,那么就是线程安全了。

二、导致线程不安全的原因

首先,可以来看一段代码,来看看是不是线程安全的,代码如下:

package com.company;

public class TestThread {

    private static class XRunnable implements Runnable{
        private int count;
        public void run(){
            for(int i= 0; i<5; i++){
                getCount();
            }
        }

        public void getCount(){
            count++;
            System.out.println(" "+count);
        }
    }

    public static void main(String[] args) {
        XRunnable runnable = new XRunnable();
        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        Thread t3 = new Thread(runnable);
        t1.start();
        t2.start();
        t3.start();
    }
}

1234567891011121314151617181920212223242526272829

输出的结果为:

 2
 3
 2
 5
 4
 7
 6
 10
 11
 12
 9
 8
 13
 14
 15
123456789101112131415

从代码上进行分析,当启动了三个线程,每个线程应该都是循环5次得出1到15的结果,但是从输出的结果,就可以看到有两个2输出,出现像这种情况表明这个方法根本就不是线程安全的。我们可以这样理解:在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存),之所以会输出两个2,是因为进程内的所有线程都可以访问到该区域,当第一个线程已经获得2这个数了,还没来得及输出,下一个线程在这段时间的空隙获得了2这个值,故输出时会输出2的值。

三、线程安全问题

要考虑线程安全问题,就需要先考虑Java并发的三大基本特性原子性可见性以及有序性

3.1 原子性

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。

那程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。

3.2 可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。

3.3 有序性

程序执行的顺序按照代码的先后顺序执行,在多线程编程时就得考虑这个问题。

案例:抢票

当多个线程同时共享,同一个全局变量或静态变量(即局部变量不会),做写的操作时,可能会发生数据冲突问题,也就是线程安全问题。但是做读操作是不会发生数据冲突问题。

Consumer类:

package com.company;

public class Consumer implements Runnable{

    private int ticket = 100;

    public void run(){
        while(ticket>0){
            System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
            ticket--;
        }
    }

}
1234567891011121314

主类:

package com.company;

public class ThreadSafeProblem {
    public static void main(String[] args){
        Consumer abc = new Consumer();

        new Thread(abc, "窗口1").start();
        new Thread(abc, "窗口2").start();
    }
}
12345678910

结果:

从输出结果来看,售票窗口买票出现了计票的问题,这就是线程安全出现问题了。

四、如何确保线程安全?

解决办法:使用多线程之间使用关键字synchronized、或者使用锁(lock),或者volatile关键字

①synchronized(自动锁,锁的创建和释放都是自动的);

②lock 手动锁(手动指定锁的创建和释放)。

③volatile关键字

为什么能解决?如果可能会发生数据冲突问题(线程不安全问题),只能让当前一个线程进行执行。代码执行完成后释放锁,然后才能让其他线程进行执行。这样的话就可以解决线程不安全问题。

4.1 synchronized关键字

4.1.1 同步代码块

synchronized(同一个锁){
  //可能会发生线程冲突问题
}
123

将可能会发生线程安全问题地代码,给包括起来,也称为同步代码块synchronized使用的锁可以是对象锁也可以是静态资源,如×××.class,只有持有锁的线程才能执行同步代码块中的代码。没持有锁的线程即使获取cpu的执行权,也进不去。

锁的释放是在synchronized同步代码执行完毕后自动释放。

同步的前提:

1,必须要有两个或者两个以上的线程 ,如果小于2个线程,则没有用,且还会消耗性能(获取锁,释放锁)

2,必须是多个线程使用同一个锁

弊端:多个线程需要判断锁,较为消耗资源、抢锁的资源。

例子:

public class ThreadSafeProblem {
    public static void main(String[] args) {
        Consumer abc = new Consumer();
        // 注意要使用同一个abc变量作为thread的参数,
        // 如果你使用了两个Consumer对象,那么就不会共享ticket了,就自然不会出现线程安全问题
        new Thread(abc,"窗口1").start();
        new Thread(abc,"窗口2").start();
    }
}
class Consumer implements Runnable{
    private int ticket = 100;
    @Override
    public void run() {
        while (ticket > 0) {
            synchronized (Consumer.class) {
                if (ticket > 0) {
                    System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
                    ticket--;
                }
            }
        }
    }
}
1234567891011121314151617181920212223

4.1.2 同步函数

就是将synchronized加在方法上。

分为两种:

第一种是非静态同步函数,即方法是非静态的,使用的this对象锁,如下代码所示

第二种是静态同步函数,即方法是用static修饰的,使用的锁是当前类的class文件(xxx.class)

public synchronized void sale () {
        if (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "售卖第" + (100-ticket+1) + "张票");
            ticket--;
        }
    }
123456

4.1.3 多线程死锁线程

如下代码所示,

线程t1,运行后在同步代码块中需要oj对象锁,,运行到sale方法时需要this对象锁

线程t2,运行后需要调用sale方法,需要先获取this锁,再获取oj对象锁

那这样就会造成,两个线程相互等待对方释放锁。就造成了死锁情况。简单来说就是:

同步中嵌套同步,导致锁无法释放。

class ThreadTrain3 implements Runnable {
	private static int count = 100;
	public boolean flag = true;
	private static Object oj = new Object();
	@Override
	public void run() {
		if (flag) {
			while (true) {
				synchronized (oj) {
					sale();
				}
			}
 
		} else {
			while (true) {
				sale();
			}
		}
	}
 
	public static synchronized void sale() {
		// 前提 多线程进行使用、多个线程只能拿到一把锁。
		// 保证只能让一个线程 在执行 缺点效率降低
		synchronized (oj) {
			if (count > 0) {
				try {
					Thread.sleep(50);
				} catch (Exception e) {
					// TODO: handle exception
				}
				System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - count + 1) + "票");
				count--;
			}
		}
	}
}
 
public class ThreadDemo3 {
	public static void main(String[] args) throws InterruptedException {
		ThreadTrain3 threadTrain1 = new ThreadTrain3();
		Thread t1 = new Thread(threadTrain1, "①号窗口");
		Thread t2 = new Thread(threadTrain1, "②号窗口");
		t1.start();
		Thread.sleep(40);
		threadTrain1.flag = false;
		t2.start();
	}
}
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748

4.2 Lock

可以视为synchronized的增强版,提供了更灵活的功能。该接口提供了限时锁等待、锁中断、锁尝试等功能。synchronized实现的同步代码块,它的锁是自动加的,且当执行完同步代码块或者抛出异常后,锁的释放也是自动的。

 Lock l = ...;
 l.lock();
 try {
   // access the resource protected by this lock
 } finally {
   l.unlock();
 }
1234567

但是Lock锁是需要手动去加锁和释放锁,所以Lock相比于synchronized更加的灵活。且还提供了更多的功能比如说

tryLock()方法会尝试获取锁,如果锁不可用则返回false,如果锁是可以使用的,那么就直接获取锁且返回true,官方代码如下:

Lock lock = ...;
 if (lock.tryLock()) {
   try {
     // manipulate protected state
   } finally {
     lock.unlock();
   }
 } else {
   // perform alternative actions
 }
12345678910

例子:

/*
 * 使用ReentrantLock类实现同步
 * */
class MyReenrantLock implements Runnable{
	//向上转型
	private Lock lock = new ReentrantLock();
	public void run() {
		//上锁
		lock.lock();
		for(int i = 0; i < 5; i++) {
			System.out.println("当前线程名: "+ Thread.currentThread().getName()+" ,i = "+i);
		}
		//释放锁
		lock.unlock();
	}
}
public class MyLock {
	public static void main(String[] args) {
		MyReenrantLock myReenrantLock =  new MyReenrantLock();
		Thread thread1 = new Thread(myReenrantLock);
		Thread thread2 = new Thread(myReenrantLock);
		Thread thread3 = new Thread(myReenrantLock);
		thread1.start();
		thread2.start();
		thread3.start();
	}
}
123456789101112131415161718192021222324252627

输出结果:

由此我们可以看出,只有当当前线程打印完毕后,其他的线程才可继续打印,线程打印的数据是分组打印,因为当前线程持有锁,但线程之间的打印顺序是随机的。

即调用lock.lock() 代码的线程就持有了“对象监视器”,其他线程只有等待锁被释放再次争抢。

4.3 volatile关键字

先来看一段错误的代码示例:

class ThreadVolatileDemo extends Thread {
	public boolean flag = true;
 
	@Override
	public void run() {
		System.out.println("子线程开始执行");
		while (flag) {
		}
		System.out.println("子线程执行结束...");
	}
	public void setFlag(boolean flag){
		this.flag=flag;
	}
 
}
 
public class ThreadVolatile {
	public static void main(String[] args) throws InterruptedException {
              ThreadVolatileDemo threadVolatileDemo = new ThreadVolatileDemo();
              threadVolatileDemo.start();
              Thread.sleep(3000);
              threadVolatileDemo.setFlag(false);
              System.out.println("flag已被修改为false!");
	}
}
12345678910111213141516171819202122232425

输出结果:

虽然flag已被修改,但是子线程依然在执行,这里产生的原因就是Java内存模型(JMM) 导致的。

由于主线程休眠了3秒,所以子线程没有意外的话是一定会被执行run方法的。而当子线程由于调用start方法而执行run方法时,会将flag这个共享变量拷贝一份副本存到线程的本地内存中。此时线程中的flag为true,即使主线程在休眠后修改了flag值为false,子线程也不会知道,即不会修改自己副本的flag值。所以这就导致了该问题的出现。

注意:在测试时,一定要让主线程进行sleep或其他耗时操作,如果没有这步操作,很有可能在子线程执行run方法而拷贝共享变量到线程本地内存之前,主线程就已经修改了flag值。

这里再来介绍一下Java内存模型吧!!!

Java内存模型规定了所有的变量(这里的变量是指成员变量,静态字段等但是不包括局部变量和方法参数,因为这是线程私有的)都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中拷贝了该线程使用到的主内存中的变量(只是副本,从主内存中拷贝了一份,放到了线程的本地内存中),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

1. 首先要将共享变量从主内存拷贝到线程自己的工作内存空间,工作内存中存储着主内存中的变量副本拷贝;

2. 线程对副本变量进行操作,(不能直接操作主内存);

3. 操作完成后通过JMM 将线程的共享变量副本与主内存进行数据的同步,将数据写入主内存中;

4. 不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

当多个线程同时访问一个数据的时候,可能本地内存没有及时刷新到主内存,所以就会发生线程安全问题

JMM是在线程调run方法的时候才将共享变量写到自己的线程本地内存中去的,而不是在调用start方法的时候。

解决办法:

当出现这种问题时,就可以使用Volatile关键字进行解决。

Volatile 关键字的作用是变量在多个线程之间可见。使用Volatile关键字将解决线程之间可见性,强制线程每次读取该值的时候都去“主内存”中取值

只需要在flag属性上加上该关键字即可。

public volatile boolean flag = true;
1

子线程每次都不是读取的线程本地内存中的副本变量了,而是直接读取主内存中的属性值。

volatile虽然具备可见性,但是不具备原子性

4.4 synchronized、volatile和Lock之间的区别

synochronizd和volatile关键字区别:

1)volatile关键字解决的是变量在多个线程之间的可见性;而sychronized关键字解决的是多个线程之间访问共享资源的同步性。

tip: final关键字也能实现可见性:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把 **“this”**的引用传递出去(this引用逃逸是一件很危险的事情,其它线程有可能通过这个引用访问到了"初始化一半"的对象),那在其他线程中就能看见final;

2)volatile只能用于修饰变量,而synchronized可以修饰方法,以及代码块。(volatile是线程同步的轻量级实现,所以volatile性能比synchronized要好,并且随着JDK新版本的发布,sychronized关键字在执行上得到很大的提升,在开发中使用synchronized关键字的比率还是比较大);

3)多线程访问volatile不会发生阻塞,而sychronized会出现阻塞;

4)volatile能保证变量在多个线程之间的可见性,但不能保证原子性;而sychronized可以保证原子性,也可以间接保证可见性,因为它会将私有内存和公有内存中的数据做同步。

线程安全包含原子性可见性两个方面。

对于用volatile修饰的变量,JVM虚拟机只是保证从主内存加载到线程工作内存的值是最新的。

一句话说明volatile的作用:实现变量在多个线程之间的可见性。

synchronized和lock区别:

1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;

2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;

3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;

4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

5)Lock可以提高多个线程进行读操作的效率(读写锁)。

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。