Java学习之线程&锁

发布时间:2023年12月17日

一、多线程

对于多线程的概念,其实很容易理解,之前我们的学习中无论多长或者怎样的代码,都是线性执行的,也就是很显而易见的自上而下执行,这也是所有语言中最常见的执行方式,那么这种执行方式有什么弊端呢?或者说有什么可以优化的点呢?

在一些较为复杂的场景里,实际上这种线性执行并不需要,举个例子:

当我们在实际应用中想要获取用户的个人信息时,可能即要用户的用户名和头像,还有用户的手机号和地址等信息,一些而用户名和头像是非隐私信息,我们可以存在一起,而手机等隐私信息显然我们需要用更加保密的方式存,既然不是存在一起的,那么线性获取就有弊端。

为什么不能同时获取呢?假设获取用户名和头像需要50ms,手机号需要50ms,同时获取仅需50ms,但是线性获取就需要100ms。

所以我们需要多线程!也就是不同的事情同时干

注:

可能学习之前我已经了解过线程之类的知识,还多了解了一个叫进程的东西,这两个实际上很有区别。

1、进程和线程的区别

进程:正在运行的程序。确切的来说,当一个程序进入内存运行,即变成一个进程,进程是处于运行过程中的程序,并且具有一定独立功能,进程是系统进行资源分配和调度的一个独立单位。进程是正在运行的程序,负责给程序分配内存空间,而每一个进程都是由程序代码组成的,这些代码在进程中执行的流程就是线程
线程:线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程中至少有一个线程。一个进程中是可以有多个线程的,这个应用程序也可以称之为多线程程。
简而言之:一个程序运行后至少有一个进程,一个进程中可以包含多个线程,但至少有一个线程

2、主线程

必然有一个执行路径(线程)从main方法开始的。一直执行到main方法结束。这个线程在java中称之为主线程。当主线程在这个程序中执行时,如果遇到了循环而导致程序在指定位置停留时间过长,无法执行下面的程序。
多线程可以解决一个主线程负责执行其中一个循环,由另一个线程负责其他代码的执行。

1、创建线程方式

1.1、继承Thread类

创建线程的步骤:

  • ?定义一个类继承Thread。
  • ?重写run()方法。
  • ?创建子类对象,就是创建线程对象。
  • ?调用start()方法,开启线程并让线程执行,同时还会告诉jvm去调用run方法。
class Demo extends Thread  //继承Thread
{
	String name;
	Demo(String name)
	{
		this.name = name;
	}
	//复写其中的run方法
	public void run()
	{
		for (int i=1;i<=20 ;i++ )
		{
			System.out.println("name="+name+",i="+i);
		}
	}
}
class ThreadDemo 
{
	public static void main(String[] args) 
	{
		//创建两个线程任务
		Demo d = new Demo("小强");
		Demo d2 = new Demo("旺财");
		//d.run(); 这里仍然是主线程在调用run方法,并没有开启两个线程
		//d2.run();
		d2.start();//开启一个线程
		d.start();//主线程在调用run方法
	}
}

继承Thread类原理

建立单独的执行路径,让多部分代码实现同时执行。也就是说线程创建并执行需要给定的代码(线程的任务)。对于之前所讲的主线程,它的任务定义在main函数中。自定义线程需要执行的任务都定义在run方法中。Thread类中的run方法内部的任务并不是我们所需要,只有重写这个run方法,既然Thread类已经定义了线程任务的位置,只要在位置中定义任务代码即可。所以进行了重写run方法动作。

?1.2、实现Runnable接口

创建线程的第二种方式:实现Runnable接口。

  1. 定义类实现Runnable接口。
  2. 覆盖接口中的run方法。
  3. 创建Thread类的对象。
  4. 将Runnable接口的子类对象作为参数传递给Thread类的构造函数。
  5. 调用Thread类的start方法开启线程。
class Demo implements Runnable{
	private String name;
	Demo(String name){
		this.name = name;
	}
	//覆盖了接口Runnable中的run方法。
	public void run(){
		for(int i=1; i<=20; i++){
            System.out.println("name="+name+"..."+Thread.currentThread().getName()+"..."+i);
		}
	}
}
class ThreadDemo2 {
	public static void main(String[] args) {
		//创建Runnable子类的对象。注意它并不是线程对象。
		Demo d = new Demo("Demo");
		//创建Thread类的对象,将Runnable接口的子类对象作为参数传递给Thread类的构造函数。
		Thread t1 = new Thread(d);
		Thread t2 = new Thread(d);
		//将线程启动。
		t1.start();
		t2.start();
		System.out.println(Thread.currentThread().getName()+"----->");
		System.out.println("Hello World!");
	}
}

实现Runnable的原理

实现Runnable接口,避免了继承Thread类的单继承局限性。覆盖Runnable接口中的run方法,将线程任务代码定义到run方法中。创建Thread类的对象,只有创建Thread类的对象才可以创建线程。线程任务已被封装到Runnable接口的run方法中,而这个run方法所属于Runnable接口的子类对象,所以将这个子类对象作为参数传递给Thread的构造函数,这样,线程对象创建时就可以明确要运行的线程的任务。

实现Runnable的好处

实现Runnable接口的方式,更加的符合面向对象,线程分为两部分,一部分线程对象,一部分线程任务。
继承Thread类,线程对象和线程任务耦合在一起。一旦创建Thread类的子类对象,既是线程对象,有又有线程任务。
实现runnable接口,将线程任务单独分离出来封装成对象,类型就是Runnable接口类型。Runnable接口对线程对象和线程任务进行解耦。

2、解析

2.1、多线程内存图解

在这里插入图片描述
多线程执行时,在栈内存中,其实每一个执行线程都有一片自己所属的栈内存空间。进行方法的压栈和弹栈。
当执行线程的任务结束了,线程自动在栈内存中释放了。但是当所有的执行线程都结束了,那么进程就结束了

2.2、获取线程名称

Thread.currentThread()获取当前线程对象

Thread.currentThread().getName();获取当前线程对象的名称

2.3、多线程的异常信息

当主线程执行完成了,并不代表程序就结束,如果此时还有其他线程正常执行,程序仍然在执行过程中。

当任何一个线程出现了异常,其他线程还是会继续运行的。异常只会影响到异常所属的那个线程。

?

3、线程的安全问题

问题产生的原因:

  1. 线程任务中可能在操作共享的数据。
  2. 线程任务操作共享数据的代码有多条(运算有多个)。(共享资源弊端隐患)

解决共享的资源

只要让一个线程在执行线程任务时将多条操作共享数据的代码执行完,在执行过程中,不要让其他线程参与运算。就可以解决这个问题。
解决这个问题Java中给我们提供相应的独立代码块,这段代码块需要使用关键字synchronized来标识其为一个同步代码块

二、synchronized?

1、同步

synchronized关键字在使用时需要一个对象作为标记,当任何线程进入synchronized标识的这段代码时,首先都会先判断目前有没有线程正在使用synchronized标记对象,若有线程正在使用这个标记对象, 那么当前这个线程就在synchronized标识的外面等待,直到获取到这个标记对象后,这个线程才能执行同步代码块

例如:

class Ticket implements Runnable{
	//1、描述票的数量。
	private int tickets = 100;
	//2、售票的动作,这个动作需要被多线程执行,那就是线程任务代码。需要定义run方法中。
	//定义同步代码块的标记对象。相当与锁的功能
	private Object obj = new Object();
	public void run(){
		//线程任务中通常都有循环结构。
		while(true){
			//使用同步代码块解决线程安全问题。
			synchronized(obj){
                //由于run方法是复写接口中的,run方法没有抛出异常, 此时这里只能捕获异常,而不能抛出
				if(tickets>0){	
					//让线程在此冻结10毫秒
					try{
						    Thread.sleep(10);
						}catch(InterruptedException e){/*异常处理代码*/}
					        System.out.println(Thread.currentThread().getName()+"....."+tickets--);//打印线程名称。
				    }
			}
		}
	}
}
class ThreadDemo3 {
	public static void main(String[] args) {
		//1,创建Runnable接口的子类对象。
		Ticket t = new Ticket();

		//2,创建四个线程对象。并将Runnable接口的子类对象作为参数传递给Thread的构造函数。
		Thread t1 = new Thread(t);
		Thread t2 = new Thread(t);
		Thread t3 = new Thread(t);
		Thread t4 = new Thread(t);

		//3,开启四个线程。
		t1.start();
		t2.start();
		t3.start();
		t4.start();
	}
}

这样一来,每一个线程操作票数的时候,其他线程是不能操作的,就不会引起负票或某一张票卖多次的情况。

1、同步的好处和弊端

同步好处:解决多线程安全问题。这里举例(火车上的卫生间)说明同步锁机制。

同步弊端:降低了程序的性能。每个线程都要去判断锁机制,那么会增加程序运行的负担,同时只要做判断,CPU都要处理,那么也会消耗CPU的资源。即就是加同步会降低程序的性能。

2、同步的前提

多个线程操作了共享数据,并且操作共享数据的代码有多句,必须使用同步代码块来解决。

当线程任务代码只会有一个线程执行时,加不加同步都可以。
当线程任务代码会有被多个线程执行时,这时需要加同步,但是加同步时一定要保证多个线程使用的是同一把锁

同步的前提:必须保证多个线程在同步中使用的是同一个锁

?3、同步代码块使用的锁

同步代码块使用的锁可以是任意对象的。因为synchronized中的对象可以我们自己指定。

4、同步函数锁

函数是需要对象去调用的,则同步函数中使用的锁就是当前调用这个方法的对象。this

5、同步代码块和同步函数的区别

  1. 同步函数使用的锁是固定的this。当线程任务只需要一个同步时完全可以使用同步函数。
  2. 同步代码块使用的锁可以是任意对象。当线程任务中需要多个同步时,必须通过锁来区分,这时必须使用同步代码块。同步代码块较为常用。
  3. 同理,若是继续向上,若是使用静态同步函数锁,就得是class文件对象

sleep和wait的区别:

相同点:可以让线程处于冻结状
不同点

  1. sleep必须指定时间;wait可以指定时间,也可以不指定时间。
  2. sleep时间到,线程处于临时阻塞或者运行;wait如果没有时间,必须要通过notify或者notifyAll唤醒 一般放在逻辑的最后。
  3. sleep不一定非要定义在同步中wait必须定义在同步中
  4. sleep和wait都定义在同步中时,线程执行到sleep,不会释放锁。线程执行到wait,会释放锁。

2、死锁

当线程任务中出现了多个同步(多个锁)时,如果同步中嵌套了其他的同步。这时容易引发一种现象:死锁。这种情况能避免就避免掉。

死锁代码(面试)

class Test implements Runnable{
    private boolean flag ;
    Test(boolean flag){
        this.flag = flag;
    }
    public void run(){
        if(flag){
            synchronized(MyLock.LOCKA){
                System.out.println(Thread.currentThread().getName()+"...if...MyLock.LOCKA");
                synchronized(MyLock.LOCKB){
                    System.out.println(Thread.currentThread().getName()+"...if...MyLock.LOCKB");
                }
            }
        }
        else {
            synchronized(MyLock.LOCKB) {
                System.out.println(Thread.currentThread().getName()+"...if...MyLock.LOCKB");
                synchronized(MyLock.LOCKA) {
                    System.out.println(Thread.currentThread().getName()+"...if...MyLock.LOCKA");
                }
            }
        }

    }
}
//单独描述锁对象
class MyLock {
    public static final MyLock LOCKA = new MyLock();
    public static final MyLock LOCKB = new MyLock();
}
class DeadThread {
    public static void main(String[] args) {
        Test t1 = new Test(true);
        Test t2 = new Test(false);
        Thread t11 = new Thread(t1);
        Thread t22 = new Thread(t2);
        t11.start();
        t22.start();
    }
}

?

2.1、死锁产生的条件

1、竞争不可抢占性资源

如上述例子中,LockA想去打开LockB,LockB又想去打开LockA,但是LockA和LockB都是不可抢占的,这是发生死锁。

2、竞争可消耗资源引起死锁

进程间通信,如果顺序不当,会产生死锁,比如p1发消息m1给p2,p1接收p3的消息m3,p2接收p1的m1,发m2给p3,p3,以此类推,如果进程之间是先发信息的那么可以完成通信,但是如果是先接收信息就会产生死锁。

3、进程推进顺序不当

进程在运行过程中,请求和释放资源的顺序不当,也同样会导致产生进程死锁。
  
死锁产生的必要条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

  • 1、互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 2、不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  • 3、请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 4、循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, …, pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有。

2.2、避免死锁的方法

1、破坏“请求和保持”条件:

想办法,让进程不要那么贪心,自己已经有了资源就不要去竞争那些不可抢占的资源。

比如,让进程在申请资源时,一次性申请所有需要用到的资源,不要一次一次来申请,当申请的资源有一些没空,那就让线程等待。

不过这个方法比较浪费资源,进程可能经常处于饥饿状态;还有一种方法是,要求进程在申请资源前,要释放自己拥有的资源。

2、破坏“不可抢占”条件:

允许进程进行抢占。

方法一:如果去抢资源,被拒绝,就释放自己的资源。

方法二:操作系统允许抢,只要你优先级大,可以抢到。

3、破坏“循环等待”条件:

将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序)提出。

死锁解决方法:

在有些情况下死锁是可以避免的。以下是避免死锁的技术:

  • 1、加锁顺序(线程按照一定的顺序加锁)
  • 2、加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  • 3、死锁检测
    • 步骤一:每个进程、每个资源制定唯一编号
    • 步骤二:设定一张资源分配表,记录各进程与占用资源之间的关系
    • 步骤三:设置一张进程等待表,记录各进程与要申请资源之间的关系
文章来源:https://blog.csdn.net/qq_45901741/article/details/135021177
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。