【Java并发编程学习 2】多线程线程安全问题

1 什么是线程安全

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

案例:需求现在有100张火车票,有三个窗口同时抢火车票,请使用多线程模拟抢票效果。

package com.lijie;

public class ThreadTrain implements Runnable {
	private int trainCount = 100;

	@Override
	public void run() {
		while (trainCount > 0) {
			try {
				Thread.sleep(50);
			} catch (Exception e) {

			}
			sale();
		}
	}

	public void sale() {
		if (trainCount > 0) {
			System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
			trainCount--;
		}
	}

	public static void main(String[] args) {
		ThreadTrain threadTrain = new ThreadTrain();
		Thread t1 = new Thread(threadTrain, "1号");
		Thread t2 = new Thread(threadTrain, "2号");
		Thread t3 = new Thread(threadTrain, "3号");
		t1.start();
		t2.start();
		t3.start();
	}
}

运行结果:
在这里插入图片描述
结论发现,多个线程共享同一个全局成员变量时,做写的操作可能会发生数据冲突问题。

2 线程安全解决办法

使用多线程之间同步synchronized或使用锁(lock)。

为什么使用线程同步或使用锁能解决线程安全问题呢
将可能会发生数据冲突问题,只能让当前一个线程进行执行。代码执行完成后释放锁,让后才能让其他线程进行执行。这样的话就可以解决线程不安全问题。

3 synchronized内置的锁

Java提供了一种内置的锁机制来支持原子性:synchronized关键字

synchronized称为内置锁,当线程进入同步代码块之前自动获取到锁,代码块执行完成正常退出或代码块中抛出异常退出时会释放掉锁

即:线程A获取到锁后,线程B阻塞直到线程A释放锁,线程B才能获取到同一个锁

3.1 synchronized的俩种使用方式

3.1.1 同步代码块方式
//就是将可能会发生线程安全问题的代码,给包括起来。
synchronized(对象)//这个对象可以为任意对象 
{ 
    需要被同步的代码 
} 

对象如同锁,持有锁的线程可以在同步中执行 ,没持有锁的线程即使获取CPU的执行权,也进不去

同步的前提:
1,必须要有两个或者两个以上的线程
2,必须是多个线程使用同一个锁
3,必须保证同步中只能有一个线程在运行
好处:解决了多线程的安全问题
弊端:多个线程需要判断锁,较为消耗资源、抢锁的资源。

代码演示:
修改上方抢票代码的sale方法

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

此时不会出现数据冲突问题
在这里插入图片描述

3.1.2 同步方法方式

在方法上修饰synchronized 称为同步方法
代码演示:

	//方法上添加synchronized 关键字即可
    public synchronized void sale() {
        if (trainCount > 0) {
            System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "张票");
            trainCount--;
        }
    }

4 多线程死锁

4.1 什么是多线程死锁

例如:对象A持有一把锁,他需要使用B中的代码,结果B也持有一把锁,他同时要使用A中的代码。这是就会发生死锁

在Java中使用多线程,就会有可能导致死锁问题。死锁会让程序一直卡住,不再程序往下执行。我们只能通过中止并重启的方式来让程序重新执行。

互相加锁引用 引起的死锁代码演示:

package com.lijie;

public class DeadLock {
    public static String obj1 = "obj1";
    public static String obj2 = "obj2";

    public static void main(String[] args) {
        Thread a = new Thread(new Lock1());
        Thread b = new Thread(new Lock2());
        a.start();
        b.start();
    }
}

class Lock1 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Lock1 running");
            while (true) {
                synchronized (DeadLock.obj1) {//加锁
                    System.out.println("Lock1 lock obj1");
                    Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2
                    synchronized (DeadLock.obj2) {//应用第二个加锁
                        System.out.println("Lock1 lock obj2");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class Lock2 implements Runnable {
    @Override
    public void run() {
        try {
            System.out.println("Lock2 running");
            while (true) {
                synchronized (DeadLock.obj2) {//加锁
                    System.out.println("Lock2 lock obj2");
                    Thread.sleep(3000);//获取obj2后先等一会儿,让Lock1有足够的时间锁住obj1
                    synchronized (DeadLock.obj1) {//应用第一个加锁
                        System.out.println("Lock2 lock obj1");
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

4.2 产生死锁的四个必要条件

  1. 互斥条件:在一段时间内某资源只由一个进程占用。如果此时还有其它进程请求资源,就只能等待,直至占有资源的进程用毕释放。

  2. 占有且等待条件:指进程已经保持至少一个资源,但又提出了新的资源请求,而该资源已被其它进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

  3. 不可抢占条件:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。

  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。(比如一个进程集合,A在等B,B在等C,C在等A)

4.3 避免死锁的方式

  1. 避免一个线程同时获得多个锁
  2. 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  3. 尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制

5 Threadlocal

5.1 什么是Threadlocal

ThreadLocal提高一个线程的局部变量,访问某个线程拥有自己局部变量。

当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

5.2 ThreadLocal的接口方法

ThreadLocal类接口很简单,只有4个方法:

  1. void set(Object value)设置当前线程的线程局部变量的值。
  2. public Object get()该方法返回当前线程所对应的线程局部变量。
  3. public void remove()将当前线程局部变量的值删除,目的是为了减少内存的占用,该方法是JDK 5.0新增的方法。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度。
  4. protected Object initialValue()返回该线程局部变量的初始值,该方法是一个protected的方法,显然是为了让子类覆盖而设计的。这个方法是一个延迟调用方法,在线程第1次调用get()或set(Object)时才执行,并且仅执行1次。ThreadLocal中的缺省实现直接返回一个null。

5.3 ThreadLocal代码演示

案例:创建三个线程,每个线程生成自己独立序列号。

package com.lijie;

class Res {
    // 生成序列号共享变量
    public static Integer count = 0;
    //创建ThreadLocal变量
    public static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() {
        protected Integer initialValue() {
            return 0;
        }
        ;
    };
    //每次获取序列号+1
    public Integer getNum() {
        int count = threadLocal.get() + 1;
        threadLocal.set(count);
        return count;
    }
}

public class ThreadLocaDemo extends Thread {
    private Res res;

    public ThreadLocaDemo(Res res) {
        this.res = res;
    }

    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println(Thread.currentThread().getName() + "---" + "i---" + i + "--num:" + res.getNum());
        }

    }

    public static void main(String[] args) {
        Res res = new Res();
        ThreadLocaDemo threadLocaDemo1 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo2 = new ThreadLocaDemo(res);
        ThreadLocaDemo threadLocaDemo3 = new ThreadLocaDemo(res);
        threadLocaDemo1.start();
        threadLocaDemo2.start();
        threadLocaDemo3.start();
    }
}

5.4 ThreadLocal实现原理

ThreadLocal底层通过map集合,Map.put(“当前线程”,值);

6多线程有三大特性

原子性、可见性、有序性
话说多线程编程必须要保证原子性、可见性以及有序性,缺一不可,不然就可能导致结果执行不正确。(我个人决定其实很多情况并不一定是需要保证三大特性的)

6.1 原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如:从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证中间不出现一些意外的问题。

6.2 可见性

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

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

6.3 有序性

程序执行的顺序按照代码的先后顺序执行。
一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

int a = 5;    //语句1
int r = 3;    //语句2
a = a + 2;    //语句3
r = a*a;      //语句4

则因为重排序,他还可能执行顺序为(这里标注的是语句的执行顺序) 2-1-3-4,1-3-2-4
但绝不可能 2-1-4-3,因为这打破了依赖关系。
显然重排序对单线程运行是不会有任何问题,但是多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。

7.多线程如何保证三大特性呢

话说多线程编程必须要保证原子性、可见性以及有序性,缺一不可,不然就可能导致结果执行不正确。(我个人决定其实很多情况并不一定是需要保证三大特性的)

7.1 保证原子性

保证多线程原子性很简单,使用synchronized或者lock锁来保证原子性

7.2 保证可见性

用好volatile关键字即可保证可见性(下一章会讲到)

7.3 保证有序性

线程一旦多起来保证有序性就很麻烦了。Java中可通过volatile在一定程序上保证顺序性,另外还可以通过synchronized和lock锁和保证原子性一样的来保证顺序性。 还可以利用ThreadLocal,让访问某个线程拥有自己局部变量巧妙的来保证。

还有除了从应用层面保证目标代码段执行的顺序性外,JVM还通过被称为happens-before原则隐式地保证顺序性。

7.4 什么是happens-before原则

简单来说就是:前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量A赋值为1,那后面一个操作肯定能知道A已经变成了1。

发布了68 篇原创文章 · 获赞 56 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_43122090/article/details/104949317