『Java练习生的自我修养』java-se进阶² • 并发与多线程

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第1张图片

☕☕ Java进阶攻坚克难,持续更新,一网打尽IO、注解、多线程…等java-se进阶内容。


前言:

多线程虽然提高了程序的执行效率,但随之而来的是线程安全问题:当多个线程访问或操作同一个资源时,就会产生意想不到的错误。

比如执行下面的代码块:

public class Demo {

    public static int x = 0;

    public static void main(String[] args) {
        new Thread(() -> x++).start();
        new Thread(() -> x++).start();
        System.out.println("x = " + x);
    }
}

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第2张图片

同时开启两个线程,每个线程都对同一个x进行自增操作,直观感觉输出结果是x = 2,然而实际的输出结果却是x = 1。这是由于自增这条代码不是原子性操作,简单理解就是两个线程同时读取了x = 0,在每个线程内部进行了一次自增操作,两个线程执行完x的值都是1,再将1写回内存,结果就相当于x只自增了一次,同我们的预期相反,这就是所谓的线程不安全。

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第3张图片


并发时的线程安全

再来看一个卖票的例子:售票站有100张票,开放三个窗口进行售票操作。用代码模拟就是有一个初始值为100的变量ticket,同时开启三个线程对ticket执行自减操作,直到ticket减到0为止。

public class TicketSales implements Runnable{

    public int ticket = 100;

    @Override
    public void run() {
        while (true) {
            if (ticket > 0) {
                try {
//                    为了让结果出现的错误更明显,设置成10ms卖一张票
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
            }
        }
    }

    public static void main(String[] args) {
        TicketSales ts = new TicketSales();
        new Thread(ts).start();
        new Thread(ts).start();
        new Thread(ts).start();
    }
}

以上代码不对线程进行任何限制,买票的结果如下:

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第4张图片

可以看到三个线程不仅会卖同一张票,甚至在最后还卖出了第101张本来不存在的票!

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第5张图片

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第6张图片

为了解决线程安全问题,就必须对访问同一资源的线程做出一定限制,在Java中使用锁机制来实现这一点。


Java中的锁机制

⛅⛅⛅
锁机制是线程同步技术的一种。既然线程的并发执行可能会导致线程不安全,那么不妨将线程的并发执行改成按顺序执行,也就是对线程中可能访问同一资源的代码片段上锁,使其在一段时间内只允许一个线程处于运行状态,而其他线程必须等待得到锁的线程执行完毕,释放出锁以后才能继续执行,通过锁机制实现多线程的同步。
⛅⛅⛅

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第7张图片

Java多线程并发的内容实在太过庞大,都可以单独写一本书了,作为刚刚接触多线程的新手来说掌握以下三种上锁方法就够用了:

  1. synchronized()对象锁
  2. synchronized同步方法
  3. Lock

1.使用锁对象

锁对象又叫对象锁、同步锁或者叫对象监视器。通过synchronized(obj){代码段}声明一个锁对象,使用obj对象作为锁,多个线程并发执行时,遇到synchronized代码块会一起争夺锁,谁抢到了谁就获得cpu执行权,执行代码块中的内容,其余线程此时进入阻塞状态;待得到锁的线程执行完代码段释放锁后,其余线程会继续争夺锁,谁抢到谁获得cpu执行权…

下面我们通过JOL对象解析工具来看一下对象被当成锁的前后有什么变化:

import org.openjdk.jol.info.ClassLayout;

public class LockTest {

    public static void main(String[] args) {
//        使用Object对象作为锁
        Object o = new Object();
//        在没声明锁时o对象的头部信息
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
        synchronized (o) {
            System.out.println("声明了一个对象锁后:");
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }
}

观察对象的头部信息:

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第8张图片

可以发现synchronized()是如何将对象当成一把锁的:改变对象头部信息的数值标记。

⭐对象锁的特点:

  • 使用一个对象作为锁,锁对象可以任意,一般使用Object o就可以。
  • 访问同一资源的多线程必须使用同一个锁对象。
  • 作用:只让一个线程在同步代码块中执行。
  • synchronized声明的是一个重量级锁,或者叫悲观锁,只有得到锁线程才能运行,其余线程都被阻塞。

通过锁对象改造卖票案例:

public class TicketSales_Solution implements Runnable{

    public int ticket = 100;

//    创建一个锁对象
    Object o = new Object();

    @Override
    public void run() {
        while (true) {
//            同步代码块
            synchronized (o) {
                if (ticket > 0) {
                    try {
    //                    为了让结果出现的错误更明显,设置成10ms卖一张票
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
                }
            }
        }
    }

    public static void main(String[] args) {
        TicketSales_Solution ts = new TicketSales_Solution();
        new Thread(ts).start();
        new Thread(ts).start();
        new Thread(ts).start();
    }
}

通过锁对象synchronized同步代码块可以解决线程同步问题,卖票案例成功得到我们想要的效果。

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第9张图片

2.使用同步方法

⭐解决线程安全问题的第二种方法—使用同步方法:

  1. 把访问了共享数据的代码抽取出来,放到一个方法中。
  2. 在方法上添加synchronized修饰符。

⭐定义方法的格式:

修饰符 synchronized 返回值类型 方法名 (参数列表) {
	可能会出现线程安全问题的代码(访问共享数据)
}

通过同步方法改造卖票案例:

public class TicketSales_Solution implements Runnable {

    public int ticket = 100;

//    将卖票的代码抽取出来
    public synchronized void payTickets() {
        if (ticket > 0) {
            try {
//                    为了让结果出现的错误更明显,设置成10ms卖一张票
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
        }
    }

    @Override
    public void run() {
        while (true) {
            payTickets();
        }
    }

    public static void main(String[] args) {
        TicketSales_Solution ts = new TicketSales_Solution();
        new Thread(ts).start();
        new Thread(ts).start();
        new Thread(ts).start();
    }
}

同样可以得到想要的结果:

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第10张图片

【注1】 既然同步方法也使用了synchronized关键字,肯定也需要一个对象作为锁,那么问题来了,充当锁的对象是谁?

  • ‍答:同步方法的锁对象是实现类对象,即当前对象,也就是我们常说的this

  • ‍答:也就是说我们抽取出来的代码还有一个等价写法—使用锁对象:

    //    将卖票的代码抽取出来
    public void payTickets() {
    //		使用锁对象,与同步方法等价的写法
    	synchronized (this) {
        	if (ticket > 0) {
            try {
    //                为了让结果出现的错误更明显,设置成10ms卖一张票
                	Thread.sleep(10);
            	} catch (InterruptedException e) {
                	e.printStackTrace();
            	}
            	System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
        	}
    	}
    }
    

【注2】 同步方法还可以在前面加上关键字static使其成为静态同步方法:

//  静态方法只能访问静态变量,这里注意要用static修饰
public static int ticket = 100;

//    将卖票的代码抽取出来,并声明为静态方法
public static synchronized void payTickets() {
    if (ticket > 0) {
        try {
//            为了让结果出现的错误更明显,设置成10ms卖一张票
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
    }
}

这里问题又来了,this是创建对象之后产生的,静态方法优先于对象,this不能当成对象锁,那么静态同步方法中谁来充当锁?

  • ‍答:静态方法的锁对象是本类的class属性。

3.使用Lock锁

Lock锁是JDK1.5之后的新增特性,相比于传统的synchronized锁,Lock锁同时提供了lock()unlock()方法,使用起来更加灵活。

解决线程安全问题的第三种方式—Lock锁:

  • Lock接口在java.util.concurrent.locks包下,其实现类为java.util.concurrent.locks.Reentrantlock
  • Lock实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。
  • Lock接口中的方法:
    • void lock():获取锁。
    • void unlock():释放锁。

使用步骤:

  1. 在成员位置创建一个ReentrantLock对象。
  2. 在可能会出现线程安全问题的代码前调用Lock接口中的lock()方法获取锁。
  3. 在可能会出现线程安全问题的代码后调用Lock接口中的lock()方法获取锁。

使用Lock锁改造卖票案例:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TicketSales_Solution implements Runnable {

    public int ticket = 100;

//    在成员位置创建一个ReentrantLock对象
    Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
//            在可能会出现线程安全问题的代码前调用Lock接口中的lock()方法获取锁
            lock.lock();
            if (ticket > 0) {
                try {
//                    为了让结果出现的错误更明显,设置成10ms卖一张票
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "卖出了第" + (101 - ticket--) + "张票");
            }
//            在可能会出现线程安全问题的代码后调用Lock接口中的lock()方法获取锁
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        TicketSales_Solution ts = new TicketSales_Solution();
        new Thread(ts).start();
        new Thread(ts).start();
        new Thread(ts).start();
    }
}

依然可以的到我们想要的结果:

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第11张图片

下篇预告:线程的等待与唤醒


创作不易,如果觉得本文对你有所帮助,欢迎点赞关注收藏。‍♀️

@作者:Mymel_晗,计算机专业练习时长两年半的Java练习生~‍♂️

文末已至,咱们下篇再见

┊且将新火试新茶,诗酒趁年华┊
望江南·超然台作-苏轼

『Java练习生的自我修养』java-se进阶² • 并发与多线程_第12张图片

你可能感兴趣的:(Java进阶指北,java,多线程,锁机制)