java多线程之线程安全(重点,难点)

线程安全

  • 1. 线程不安全的原因:
    • 1.1 抢占式执行
    • 1.2 多个线程修改同一个变量
    • 1.3 修改操作不是原子的
    • 锁(synchronized)
      • 1.一个锁对应一个锁对象.
      • 2.多个锁对应一个锁对象.
      • 2.多个锁对应多个锁对象.
      • 4. 找出代码错误
      • 5. 锁的另一种用法
    • 1.4 内存可见性
      • 解决内存可见性引发的线程安全问题(volatile)
    • 1.5 指令重排序

由于操作系统中,线程的调度是抢占式执行的,或者说是随机的,这就造成线程调度执行时,线程的执行顺序是不确定的,虽然有一些代码在这种执行顺序不同的情况下也不会运行出错,但是还有一部分代码会因为执行顺序发生改变而受到影响,这就会造成程序出现Bug,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码.

本质原因: 线程在系统中的调度是无序的/随机的(抢占式执行)

1. 线程不安全的原因:

序号线程不安全的原因
1抢占式执行(罪魁祸首)
2多个线程同时修改同一个变量
3修改操作不是原子的
4内存可见性
5指令重排序

多线程不安全的原因主要分为一下三种:

1.原子性

  • 多行指令,如果指令前后有依赖关系,不能插入其他影响自身线程执行结果的指令

2.可见性

  • 系统调用CPU执行线程内,一个线程对共享变量的修改,另一个线程能够立刻看到

3.有序性

  • 程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)

1.1 抢占式执行

我们通过下面的代码来进行讲解:

class Demo{
    private static int count;
	//
    public static void countAdd(){
        count++;
    }
	//返回count
    public static int getCount() {
        return count;
    }
}

public class ThreadDemo11 {

	public static void main(String[] args) throws InterruptedException {
	        Thread t1 = new Thread(()->{
	            for (int i = 0; i < 50000; i++) {
	            //执行50000次count++
	                Demo.countAdd();
	            }
	        });
	        Thread t2 = new Thread(()->{
	            for (int i = 0; i < 50000; i++) {
	            //执行50000次count++
	                Demo.countAdd();
	            }
	        });
	        //执行t1线程
	        t1.start();
	
	        //执行t2线程
	        t2.start();
	
	        //等待t1,t2线程执行完
	        t1.join();
	        t2.join();
	
	        //打印此时的count值
	        System.out.println(Demo.getCount());
    }

此时我们可以看到,线程 t1 和线程 t2 分别对count进行50000次的自增,那么我们最后打印的值应该是100000吧~

此时我们运行看一下结果:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

大家会发现,每次的运行结果都不一样,并且没有一次的值是正确的,到底是为什么呢?

小鱼给大家画图解释下:

在这里插入图片描述

  • load: 从内存中将值读取到cpu寄存器中

  • add: 将cpu寄存器的值进行+1

  • save: 将寄存器的值读取到内存中

此时这里的结果错误,就是因为count++不是原子的而造成.

为什么这么说呢? 为了方便大家理解,我们用两个cpu内核来举例.内两个圆圆的东西就是 t1 的工作内存和 t2 的工作内存.

工作内存包含:cpu寄存器和缓存…

在这里插入图片描述

此时我们可以观看到,我们是在执行完 t1 线程的count++之后再去执行 t2 的count++的.此时我们的count是2,是正确的~~

但是呢,由于线程的随即调度,我们可能会存在这种情况:

在这里插入图片描述

由于线程是抢占式执行的,所以可能会存在,当 t1 线程刚进行 load 之后, t2 就也进行了 load,就是上图中左侧线程执行的顺序.当然,类似于这种插队式的组合方法多的数不清,上图运行的结果是count=1,但是我们的count已经自增两次了啊,应该是2的,此时出现的错误,就是线程安全的问题.

下面是一部分可能出现的随机排列情况:

在这里插入图片描述

1.2 多个线程修改同一个变量

上述的多线程安全问题就是因为多个线程对同一变量进行修改造成的.

大家看下面的表格:

原因安全性
一个线程修改一个变量安全
多个线程修改一个变量不安全
多个线程读取多个变量安全
多个线程修改多个不同变量安全

1.3 修改操作不是原子的

原子性: 不可分割的最小单位.

在这里插入图片描述

通过上述过程我们知道,正因为有些操作不是原子的,导致两个或多个线程的指令排序存在更多的变数,自然就引发线程不安全的问题.

关于内存可见性,和指令重排序在后面讲到…

那我们如何解决上述的线程安全问题呢?

我们通过count++ 举例,如何让count++ 变成原子的呢?

我们可以通过加锁的方式将count++ 变成原子的.

锁(synchronized)

锁的核心操作分为两个:

(1) 加锁 : 当我们进入这个房间之后,别人就无法进入这个房间.

(2) 解锁 : 只有我们打开门,释放锁之后,别人才可以进入这个房间.

如果我进入这个房间之后,还有别人想要进入,就需要等我释放锁,走出这个房间才可以进入,别人在门口等待我的过程称为"阻塞".

当然,由于线程是抢占式执行的,所以如果我想进入这个房间就需要和那些都想进入这个房间的人进行争抢,当我进入这个房间之后,此时一群人只能在外面等着我,不能干别的事情,当我出了这个房间之后,他们又会开始新一轮的争抢,如果我还有进入房间的需要,我也会再次和他们一起争抢.

锁(synchronized) 这个关键字还有一个参数需要传进去,这个参数的类型需要是object或者它的子类

1.一个锁对应一个锁对象.

关于这个锁对象的用途,这里通过生活中的例子讲解…

我们假设有两个老师,A,B他们都是教英语的,学校规定,这个学期英语课只有50节,工资按照你们每个人上课的次数决定,由于上课的教室只有一个,所以A,B老师先到先得.因为老师上课的时候不能被打扰,所以规定,这个教室一次只能进入一个老师,假设A老师在讲课,那么B老师如果想讲课的话只能等着,且不能做别的事情,只能等A老师讲完课并且出门之后,再次和A老师竞争上课的资格.

上面涉及到的知识点有:

(1) A,B老师先到先得(抢占式执行)(也可以称为锁竞争)

(2) 学校规定,一次只能进去一个老师(加锁)

(3)如果A老师讲课,B老师如果想讲课的话只能等着(阻塞)

(4) 只能等A老师讲完课并且出门之后(执行完锁内的代码块,并释放锁)

也可以用代码举例:

我们这里假设两个老师讲的课程一模一样,这50节课的内容从来没变.

class Teacher{
    static Object object = new Object();
    public static void AttendClass(){
        //此时两个进程都调用这个方法,为了防止老师讲课被打扰
        //对这个讲课进行加锁
        //此时这个锁的参数,也可以称为锁对象
        //我们可以将锁对象理解为教室,
        synchronized (object){
        //课程
            System.out.println("ABCDEFG....");
            ThreadDemo14.size--;

        }
    }
}
public class ThreadDemo14 {
   static int size = 50;
    public static void main(String[] args) {
        Thread A = new Thread(()->{
            while (size >= 0)
                    Teacher.AttendClass();
        }) ;
        Thread B = new Thread(()->{
            while (size >= 0)
                    Teacher.AttendClass();
        }) ;

        A.start();
        B.start();
    }
}

上述讲的例子是只在教室里面讲英语(只有一个锁,且只有一个锁对象).下面这个例子是不仅仅是讲英语…

2.多个锁对应一个锁对象.

依旧是先语言表达一下:

A,B两个老师,A英语老师,B语文老师,但是呢,只有一个教室可以用来上课,此时A,B老师就会为了这个教室的使用权而争抢,没有抢到的老师只能等着,依旧是50节课,谁上的课多谁工资高!

代码举例:

class Teacher{
    static Object object = new Object();
    public static void EnglishClass(){
    //注意这里
        synchronized (object){
            System.out.println("ABCDEFG...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }

    public static void ChineseClass(){
    //注意这里
        synchronized (object){
            System.out.println("鹅鹅鹅,曲项向天歌...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }
}
public class ThreadDemo14 {
   static int size = 50;
    public static void main(String[] args) {
        Thread A = new Thread(()->{
            while (size >= 0)
                    Teacher.EnglishClass();
        }) ;
        Thread B = new Thread(()->{
            while (size >= 0)
                    Teacher.ChineseClass();
        }) ;

        A.start();
        B.start();
    }
}

上述代码和之前的代码的区别就是,我上了两个锁,但是锁对象一样(多个锁,但是只有一个锁对象),这是什么意思呢? 意思就是,虽然我们讲的是不同的课程(锁的代码块不一样),但是这个锁对象一样,意味着我们需要在同一个教室讲课,一次只能有一个老师讲课.

更通俗点理解就是:不管你多少个老师,不管你教什么,只要是同一个教室(锁对象一样)那么,你就只能等这个教室空出来才能用…在此期间

2.多个锁对应多个锁对象.

但是呀,如果多个老师争抢一个教室,那么一定会引发不满的,所以个,学校就多建造了几个教室,那么这些老师就可以在不同的教室上课了…

class Teacher{
//教室1
    static Object classroom1 = new Object();
  //教室2
    static Object classroom2 = new Object();
    public static void EnglishClass(){
    //此时锁对象不同,意味着在不同的教室
        synchronized (classroom1){
            System.out.println("ABCDEFG...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }

    public static void ChineseClass(){
    //此时锁对象不同,意味着在不同的教室
        synchronized (class2room){
            System.out.println("鹅鹅鹅,曲项向天歌...."+ThreadDemo14.size);
            ThreadDemo14.size--;

        }
    }
}

此时我们大概了解锁对象的意思了,科学一点的解释如下:

总结:
(1) 多个代码块使用了同一个同步监视器 ( 锁 ) , 锁住一个代码块的同时,也锁住所有使用该锁的所有代码块,其他线程无法访问其中任何一个代码块.

(2) 多个代码块使用了同一个同步监视器 ( 锁 ) , 锁住一个代码块的同时,也锁住了所有该锁的所有代码块,但是没有锁柱使用其他同步监视器的代码块,其他线程有机会访问其他同步监视器的代码块.

4. 找出代码错误

你也为锁这块讲完了吗?

天真! 上面还有一处错误! 不知道同学发现了没有…

我们来运行下两个英语老师的那个例子:
在这里插入图片描述

咦,怎么打印出来了-1,这是为什么呢?

有一种情况我们没有考虑到,就是…

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

那么怎么解决呢?

在这里插入图片描述
此时的运行结果:

在这里插入图片描述
可能这个方法不是最优的,因为每次进入锁又需要进行一次if(),这些都是时间上的开销!!!

5. 锁的另一种用法

我们可以用synchronized来修饰方法,如果是用锁来修饰方法的话,我们不需要给这个锁设置参数,如果这个方法是静态的,那么这个锁的对象就是类名(该方法所处的类).class,如果是非静态的方法,那么谁调用这个方法,谁就是锁对象,也就是this.

代码如下:

非静态方法:

   public  void func1(){
        synchronized (this){
            
        }
    }
    //两者等价
    synchronized public void func2(){
        
    }

静态方法:

class A1{
    public static void func1(){
        synchronized (A.class){

        }
    }
    //两者等价
    synchronized public void func2(){

    }

}

1.4 内存可见性

什么是内存可见性呢? 大家看下面的代码,猜猜运行结果!

public class ThreadDemo15 {
    static boolean flog = false;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (!flog){
                //什么都不打印
            }
            //循环执行结束
            System.out.println("循环执行结束");
        });

        t1.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flog = true;
        System.out.println("flog改为true");
    }
}

两个选项:

A. 打印完flog之后就程序结束
B. 打印完flog之后程序没有结束

现在请看运行结果…

在这里插入图片描述

正确答案是B,为什么呢?

小鱼为大家解答.

我们通过一个例子来解释:

一天呢,玉帝派给孙悟空一个任务,要求他一直看着唐僧,看他会不会怀孕…
这孙悟空一听,男的会怀孕? 那不可能啊…

于是玉帝每次问孙悟空,孙悟空看都不看唐僧一眼就说没怀孕,不知过了多久,唐僧因为喝了女儿国的水,怀孕了!!! 就在唐僧怀孕之后,玉帝问孙悟空,你师傅怀孕了嘛?孙悟空依旧说不屑的回答:“没有”,殊不知唐僧孩子都快生出来了…

我们刚才的程序为什么会在我修改flog之后也一直在继续执行呢?

是因为,while(flog != false) 需要两步,从内存中读取flog的值到自己的寄存器,再将寄存器的值和false比较,由于呢~读内存(load)的操作很是麻烦,所以编译器就自作主张,想要优化这个代码,于是就不再去内存中读取了,直接将自己寄存器的值和false进行比较,但是这一不读取就出现了意外,代码内心: 你小子看我一眼啊,我都变了,我都变成true了,你丫还在循环!!!

上面出现线程安全的主要原因就是编译器优化,因为读内存的操作比读寄存器要慢几千倍,所以编译器为了运行效率,擅自做了决定.

所谓内存可见性就是在多线程的情况下,编译器对于代码优化,产生了误判,从而引起的一系列Bug,进而导致咱们的代码bug了.

解决内存可见性引发的线程安全问题(volatile)

我们对比这个代码:

public class ThreadDemo15 {
    static boolean flog = false;
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while (!flog){
                //什么都不打印
                //加入了一个时间限制
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //循环执行结束
            System.out.println("循环执行结束");
        });

        t1.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flog = true;
        System.out.println("flog改为true");
    }
}

运行结果:

在这里插入图片描述

这个代码有sleep,加上sleep循环执行速度就变得很慢,当循环次数下降了,此时load不再是负担,编译器就没必要优化了.

但是我如果我就想让循环空转,并且还不能出错,该怎么处理?

咱么可以让编译器针对这个场景暂停优化.

如何做到呢?

有请接下来的主角: volatile

我们通过使用volatile关键字修饰变量,此时该变量就会禁止编译器优化,能够保证每次都是从内存中重新读取数据.

在这里插入图片描述

volatile是一个类型修饰符,作用是作为指令关键字,一般都是和const对应,确保本条指令不会被编译器的优化而忽略。

1.5 指令重排序

volatile还有一个用处就是禁止指令重排序.

指令重排序也是编译器优化的策略,调整了代码的执行顺序,让程序更高效.

前提: 保证代码逻辑不变,并且调整之后的结果要和之前是一样的.

关于指令重排序,小鱼给大家举个例子吧…

妈妈今天让小鱼去菜市场买菜,把买菜的清单列给了小鱼.
在这里插入图片描述

小鱼发现,如果按照清单上的顺序购买,比较浪费时间.

在这里插入图片描述

小鱼于是呢,就想换个路线…

在这里插入图片描述

当小鱼买完西红柿之后呢,妈妈给小鱼打电话,问小鱼买完西红柿了嘛,小鱼说买完了,妈妈说,那就抓紧回家吧~~

此时小鱼就到了家里,妈妈看到小鱼手里的西红柿陷入了沉思…

怎么只有西红柿,别的菜呢? 然后小鱼就挨打了,因为妈妈以为他是按照清单上的顺序去买的,当看到小鱼买完西红柿之后以他都买好了,就让他回来了,结果今天只能吃凉拌西红柿了…

我们也可以用代码来解释:

class Student {
	//成员变量
    static Student s;
    //成员方法
    public static Student getS() {
        return s;
    }
	//
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            s = new Student();
        });
        Thread t2 = new Thread(()->{
            if(s != null) {
                s.getClass();
            }
        });
		t1.start();
		t2.start();
    }
}
s = new Studnet();

这和new的过程可以大体分为三部分.

1.申请内存空间
2.调用构造方法
3.把对象的引用赋值给 s

如果是在单线程的环境下,1,2,3的指令可以发生重排序,1先执行,2和3谁先谁后都可以.

在单线程情况下,这种优化并不会出现什么问题,但是在多线程情况下就不好说了…

在这里插入图片描述
此时呢,为了避免指令重排序产生的线程安全问题,我们需要将

   volatile static Student s;//进行volatile修饰

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/2955.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

乐观锁和悲观锁 面试题

Mysql的乐观锁和悲观锁 实现方式加锁时机常见的调用方式优势不足适用场景乐观锁开发自定义更新数据的时候sql语句中进行version的判断高并发容易出现不一致的问题高并发读&#xff0c;少写悲观锁Mysql内置查询数据的开始select * for update保证一致性低并发互联网高并发场景极…

linux实验之shell编程基础

这世间&#xff0c;青山灼灼&#xff0c;星光杳杳&#xff0c;秋风渐渐&#xff0c;晚风慢慢 shell编程基础熟悉shell编程的有关机制&#xff0c;如标准流。学习Linux环境变量设置文件及其内容/etc/profile/etc/bashrc/etc/environment~/.profile~/.bashrc熟悉编程有关基础命令…

JVM类加载机制

文章目录定义类加载过程加载链接验证准备解析初始化类加载器双亲委派模型定义 Java 虚拟机把描述类的数据从 Class 文件加载到内存&#xff0c;并对数据进行校验、转换解析和初始化&#xff0c;最终形成可以被虚拟机直接使用的 Java 类型&#xff0c;这个过程被称为虚拟机的类…

有手就行 -- 搭建图床(PicGo+腾讯云)

&#x1f373;作者&#xff1a;贤蛋大眼萌&#xff0c;一名很普通但不想普通的程序媛\color{#FF0000}{贤蛋 大眼萌 &#xff0c;一名很普通但不想普通的程序媛}贤蛋大眼萌&#xff0c;一名很普通但不想普通的程序媛&#x1f933; &#x1f64a;语录&#xff1a;多一些不为什么的…

2023最新最详细【接口测试总结】

序章 ​ 说起接口测试&#xff0c;网上有很多例子&#xff0c;但是当初做为新手的我来说&#xff0c;看了不不知道他们说的什么&#xff0c;觉得接口测试&#xff0c;好高大上。认为学会了接口测试就能屌丝逆袭&#xff0c;走上人生巅峰&#xff0c;迎娶白富美。因此学了点开发…

嵌入式学习笔记——SysTick(系统滴答)

系统滴答前言SysTick概述SysTick是个啥SysTick结构框图1. 时钟选择2.计数器部分3.中断部分工作一个计数周期&#xff08;从重装载值减到0&#xff09;的最大延时时间工作流程SysTick寄存器1.控制和状态寄存器SysTick->CTRL2.重装载值寄存器SysTick->LOAD3.当前值寄存器Sy…

async与await异步编程

ECMA2017中新加入了两个关键字async与await 简单来说它们是基于promise之上的的语法糖&#xff0c;可以让异步操作更加地简单明了 首先我们需要用async关键字&#xff0c;将函数标记为异步函数 async function f() {} f()异步函数就是指&#xff1a;返回值为promise对象的函…

51单片机之喝水提醒器

定时器定时器介绍晶振晶体震荡器&#xff0c;又称数字电路的“心脏”&#xff0c;是各种电子产品里面必不可少的频率元器件。数字电路的所有工作都离不开时钟&#xff0c;晶振的好坏、晶振电路设计的好坏&#xff0c;会影响到整个系统的稳定性。时钟周期时钟周期也称为振荡周期…

数据库备份

数据库备份&#xff0c;恢复实操 策略一&#xff1a;&#xff08;文件系统备份工具 cp&#xff09;&#xff08;适合小型数据库&#xff0c;是最可靠的&#xff09; 1、停止MySQL服务器。 2、直接复制整个数据库目录。注意&#xff1a;使用这种方法最好还原到相同版本服务器中&…

银河麒麟v10sp2安装nginx

nginx官网下载&#xff1a;http://nginx.org/download/ 银河麒麟系统请先检查yum源是否配置&#xff0c;若没有配置请参考&#xff1a;https://qdhhkj.blog.csdn.net/article/details/129680789 一、安装 1、yum安装依赖 yum install gcc gcc-c make unzip pcre pcre-devel …

用嘴写代码?继ChatGPT和NewBing之后,微软又开始整活了,Github Copilot X!

用嘴写代码&#xff1f;继ChatGPT和NewBing之后&#xff0c;微软又开始整活了&#xff0c;Github Copilot X&#xff01; AI盛行的时代来临了&#xff0c;在这段时间&#xff0c;除了爆火的GPT3.5后&#xff0c;OpenAI发布了GPT4版本&#xff0c;同时微软也在Bing上开始加入了A…

新版logcat最全使用指南

前言&#xff1a; 俗话说&#xff0c;工欲善其事&#xff0c;必先利其器。logcat是我们通过日志排查bug的重要武器之一。从某个版本开始&#xff0c;logcat改版了&#xff0c;改版之后&#xff0c;也许某些人觉得不太习惯&#xff0c;但是如果稍微学习下之后&#xff0c;就发现…

从 X 入门Pytorch——BN、LN、IN、GN 四种归一化层的代码使用和原理

Pytorch中四种归一化层的原理和代码使用前言1 Batch Normalization&#xff08;2015年提出&#xff09;Pytorch官网解释原理Pytorch代码示例2 Layer Normalization&#xff08;2016年提出&#xff09;Pytorch官网解释原理Pytorch代码示例3 Instance Normalization&#xff08;2…

AJAX,Axios,JSON简单了解

一. AJAX简介概念: AJAX(Asynchronous JavaScript And XML): 异步的JavaScript 和XMLAJAX作用:1.与服务器进行数据交换: 通过AJAX可以给服务器发送请求&#xff0c;并获取服务器响应的数据使用了AJAX和服务器进行通信&#xff0c;就可以使用 HTMLAJAX来替换JSP页面了2.异步交互…

ChatGPT文心一言逻辑大比拼(一)

❤️觉得内容不错的话&#xff0c;欢迎点赞收藏加关注&#x1f60a;&#x1f60a;&#x1f60a;&#xff0c;后续会继续输入更多优质内容❤️&#x1f449;有问题欢迎大家加关注私戳或者评论&#xff08;包括但不限于NLP算法相关&#xff0c;linux学习相关&#xff0c;读研读博…

静态通讯录,适合初学者的手把手一条龙讲解

数据结构的顺序表和链表是一个比较困难的点&#xff0c;初见会让我们觉得有点困难&#xff0c;正巧C语言中有一个类似于顺序表和链表的小程序——通讯录。我们今天就来讲一讲通讯录的实现&#xff0c;也有利于之后顺序表和链表的学习。 目录 0.通讯录的初始化 1.菜单的创建…

python例程:五子棋(控制台版)程序

目录《五子棋&#xff08;控制台版&#xff09;》程序使用说明程序示例代码可执行程序及源码下载路径《五子棋&#xff08;控制台版&#xff09;》程序使用说明 在PyCharm中运行《五子棋&#xff08;控制台版&#xff09;》即可进入如图1所示的系统主界面。 图1 游戏主界面 具…

一个比较全面的C#公共帮助类

上次跟大家推荐过2个C#开发工具箱&#xff1a;《推荐一个不到2MB的C#开发工具箱&#xff0c;集成了上千个常用操作类》、《推荐一个.Net常用代码集合&#xff0c;助你高效完成业务》。 今天再给大家推荐一个&#xff0c;这几个部分代码功能有重合的部分&#xff0c;大家可以根…

静态版通讯录——“C”

各位CSDN的uu你们好呀&#xff0c;之前小雅兰学过了一些结构体、枚举、联合的知识&#xff0c;现在&#xff0c;小雅兰把这些知识实践一下&#xff0c;那么&#xff0c;就让我们进入通讯录的世界吧 实现一个通讯录&#xff1a; 可以存放100个人的信息每个人的信息&#xff1a;名…

FPGA打砖块游戏设计(有上板照片)VHDL

这是一款经典打砖块游戏,我们的努力让它更精致更好玩,我们将它取名为打砖块游戏(Flyball),以下是该系统的一些基本功能:  画面简约而经典,色彩绚丽而活泼,动画流畅  玩家顺序挑战3个不同难度的级别,趣味十足  计分功能,卡通字母数字  4条生命值,由生命条显示…