小度分享-【多线程工作及线程安全】

321 阅读11分钟
*本篇文章适用于初学,入门的朋友,若有不足或有误的地方望大家提出建议指正

线程是什么?

在讲线程之前,我们需要了解一下什么是 进程,进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位。

简单来说,线程是组成进程的“单位元”。

一个线程只能属于一个进程,但是一个进程可以拥有多个线程。多线程处理就是允许一个进程中在同一时间调用多个线程执行一个或多个任务。

为什么需要多线程呢?

答:自从寝室卫生间的马桶从一个马桶(单线程)升级成5个马桶(多线程)之后,妈妈再也不用担心我早上抢不到厕所啦,美滋滋!

请看下面的🌰,带你理解一些进程和线程中的概念。

假设现在有这样一个场景 :

有一些工人工人线程),这些工人被要求分配到一个矿山去采矿,而矿山有很多开采的矿穴进程),要求工人们去搬运,而矿穴很窄,一次只能通过一个人。

  • 场景一:这些工人的关系都不怎么好,一个工人负责一个矿穴,且只负责这个矿穴,互不干扰,多出的工人就围观。(串行
  • 场景二:这些工人的关系都不错,对于一个矿穴来说有多个工人同时去排队搬运。(并发
  • 场景三:对于多个洞穴有多个工人同时排队去采矿(并行
  • 场景四:搬运工人见矿穴有工友进去采矿了,然后愣在洞口前,直到里面的人出来(同步
  • 场景五:搬运工人见矿穴有工友进去采矿后,马上去另外一个洞穴帮忙(异步
  • 场景六:搬运工人得知这个洞穴里面的开采队没有采到矿,就一直在洞口等,直到采到矿为止 (阻塞
  • 场景七:搬运工人得知这个洞穴里面的开采队没有采到矿,果断地回去了...(非阻塞

所以我们得到以下结论

• 线程之间可以是平行关系,即各个线程的工作处理没有交叉,也可以是共点关系,即多个线程为一个或多个程序的运行做贡献

但在程序实际运行过程中,一般情况下线程之间的运行顺序是随机的,被提到CPU上去处理也是随缘的。

线程的运行大概就如图所示

但是我们仍然能通过一些手段去协调、干预线程之间的运行。

比如利用sleep方法,对线程进行休眠,在多个线程并发运行时,一个线程被"睡"上个1ms,对其他线程来说已经得到了极大的机会去获取Running的机会。

在实际应用中,还有很多工具可以协调线程之间的运行,去更合理和高效地协调线程之间资源利用,以及保证多线程之间的安全运行

线程是如何运行的

这就不得不提到JMM(Java Memory Model),Java内存模型。

在JVM内部使用的java内存模型(JMM)将线程堆栈和堆之间的内存分开 ,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。也就是说,通过这样一种机制,消除了线程之间的差异,协调多线程工作,保证了在进程中的一致性。JMM的抽象示意图如下。

上图便是多线程工作的的执行流程,每个线程都可以主内存中的共享变量X进行更改操作,但是线程不能直接对主内存中的共享变量进行访问 都有自己的工作内存,如果线程想要对主内存中的X进行操作,需要先将该变量拷贝至线程自己的工作内存中一份,然后在本地进行更改操作之后,再将最终的结果同步至主内存中。

在线程并发过程中,可见性原子性有序性,是JMM维持线程秩序的重要特性。

原子性:

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。强调了线程执行的完整性

能够很好体现出原子性的重要性的🌰:

银行存取款问题

小明同学的账户中存有余额1000元,某天他去银行取存入100元,然后又马上取200元,如此一来账户中应该剩余900才对,但如果负责存取款操作的两个线程不具备原子性的话,可能账户只剩下800元咯,欸?这是怎么回事呢,我们一起来看下。

现在有 【银行数据系统 ,进程->主内存】 、【存、取款系统,线程->工作内存

存取款流程(这里以存款过程为例,取款同理): 存款系统获取银行数据系统中的账户余额信息,然后先将用户存款额在存在存款系统中,随后再同步银行数据系统


存款线程:
//getBalance得到的是银行数据系统的账户余额信息
public void saveAccount() {

                //得当前账户余额  ----------------A1
		int balance = getBalance();
		// 修改余额,存100元 -------------A2
		balance += 100;
		// 修改账户余额-------------------A3
		setBalance(balance);
	}
取款线程:
public void drawAccount() {

	   	// 获得当前户余额---------------B1
		int balance = getBalance();
		// 修改余额,取200 -------------B2
		balance = balance - 200;
		// 修改账户余额 ----------------B3
		setBalance(balance);
		
	}

好了,现在我们再来研究一下那不翼而飞的100元是怎么回事吧,如果原子性不能被保证,任何线程任何时间执行到任何位置都可能被其他线程打断。

  • 先执行的存款操作如果在执行完A2后被打断

这时账户余额信息仅仅是存在balance中,并没有同步至银行数据库(主内存)。

  • 然后执行取款操作

这时取款线程得到的getBalance,还是1000元,因为刚才取款线程并没有将数据更新至setBalance。

  • 但是在执行完B2后又被打断,继续执行A3的操作

取款线程也没有将运算后的balance同步至setBalance,然后转调到存款操作,此时setBalance的余额更新至1100元。

  • 此时执行完A3,再次返回取款线程执行最后的B3

在取款线程中的balance是800元,同步之后setBalance的数值再次被更新,最终余额为800元。


可见,要保证程序的原子性,一定要保证线程代码的完整执行



可见性:

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。强调了线程将运行结果同步至主内存的及时性

可见性与原子性其实关注的都是代码执行完整的问题,如果一个线程不能及时地将工作内存中的处理结果同步至主内存,那就和执行中途被打断没什么区别了😅。

🌰:

    //线程A
    int x = 0;
    x = 1;
    
    //线程B
    y = x;

如果线程A执行完了所有操作,但是最后没有及时地将结果同步至工作内存,那么再执行线程B的值就会赋为0而不是1。



有序性:

即程序执行的顺序按照代码的先后顺序执行。但在多线程的环境下,CPU为了高运行效率,可能会可能会发生指令重排的情况,即线程间运行 无序化,可能难以保证写入主内存最终结果的正确性

🌰:

    int a = 3;
    int b = 3;
    a++;
    b+=a;

也许你会担心指令重排对这段代码运行的影响,但是事实上发生重排绝对不会发生在第3、4行代码上,处理器在进行重排时会考虑数据之间的依赖性。

但是下面的🌰就没这么幸运了:

最典型的生产消费关系

    int store = 2;//库存量
    //省略getter和setter方法
    //线程A
    public void Producer(){
        setStore +=1;·
    }
    //线程B
    public void Consumer(){
        setStore -=1;·
    }

因为线程A和B没有了直接数据依赖,因此可能会被重排序,但是考虑现实情况的结果,生产量应大于消费量,因此发生重排后会导致线程出错



针对这些在多线程运行时的种种疑难杂症,JMM提供了很多方法和机制以确保我们在多线程编程时执行程序的正确性。

线程安全

到现在为止现在我们知道使线程间顺利执行的保障就是 可见性原子性有序性,我们也针对这三条性质展开讨论。

原子性:

这边利用 Synchronized 方法解决奥

相当于在多线程运行时保护一个线程的完整运行
就比如刚才的例子,如果能把这两行代码”包装“起来,就不会被其他线程打断了
public synchronized void saveAccount() {
		// 获得当前账户余额
		int balance = getBalance();
		// 修改余额,存100元
		balance += 100;
		// 修改账户余额
		setBalance(balance);
	}

	public void drawAccount() {
		synchronized (this) {//this当前类
			// 在不同的位置处添加sleep方法
			// 获得当前账户余额
			int balance = getBalance();
			// 修改余额,取200
			balance = balance - 200;
			// 修改账户余额
			setBalance(balance);
		}
	}

这样就好啦。

但是仍然存在很多的问题,被synchronized关键字描述的方法或者代码块,在多线程环境下同一时间只能由一个线程进行访问,在其他被synchronized描述的线程完成之前,其他线程想要调用相关方法就必须进行排队,直到那个线程执行结束。

而且Synchronized 还有很多局限性,它只可以用在 o 成员方法 o 静态方法 o 语句块 比如这样 public synchronized void saveAccount(){}

public static synchronized void saveAccount(){}

synchronized (obj){......}

所以不可以随心所欲地操作,对线程锁死的区域是固定的,只要有一个线程进入了,其他线程都要进入无线等待状态。

这就要 LOCK 登场了

相比synchronizedLOCK更潇洒一点 例如:

Lock lock = new ReentrantLock();

获取锁对象后,用Lock中的lock和unlock方法进行锁死

    lock.lock();
 
    lock.unlock();

使用lock的好处是锁死区域会灵活一些

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

但是Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断,且通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。

总之在有大量线程并发的情况下 Lock是拥有绝对的性能优势的



可见性:

那么对可见性的保障就由volatile关键字来实现了

当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

其实synchronizedlock也一样可以保证可见性,只要保证在释放锁之前同步到主内存即可。



有序性:

很显然synchronizedlock也一样可以维持有序性,不过这里我们也可以用一个最经典的机制处理

(wait/notify)两个方法搭配使用的机制

最经典的套路

private int n;
boolean flag = false;
//为防止消费大于生产的情况,设置flag
public synchronized int get() {
    if(!flag) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("消费" + n);
    flag = false;//消费完毕,容器中没有数据
    notifyAll();
    return n;
}
 
public synchronized void set(int n) {
    if(flag) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    System.out.println("生产:"+ n);
    this.n = n;
    flag = false; //生产完毕,容器中已经有数据
    notifyAll();
}

可以利用这种交替进行的方式,来控制线程间的有序性 线程A进入wait状态的时候,线程B进行,待线程B结束后,通知线程A结束wait状态, 轮到线程B的时候也是重复这个过程,不断交替进行。





总结: 虽然说线程之间的切换地,并发地运行可能会提高整个程序运行的性能,不过在特殊情况下确实是需要分别操作的,此篇文章也更多讨论的是线程运行完整性的内容。

在一些限制工具的帮助下,对线程锁死封装,对线程的生命周期的状态合理安排,就能对线程完整运行有了保障,又可以避免其他线程对齐的干扰