概述
学习java断断续续加起来已经有快三十天的时间了,之前的博客按照时间顺序写的,今天对其进行一次整合,像期末复习一样将表面内容略去,详细介绍重点及难点。
框架
刚好之前做学习ppt汇报的时候做了几张图,这里展示一下。
基础
编程语法
java是一个以面向对象编程为目的而开发的语言,以C++为基础,摒弃了C++中许多繁琐的操作,并按照面向对象的思想在底层进行优化。
java的基础语法很容易掌握,唯一有点困惑的地方大概就是java的引用概念,这一点其实在了解了java的运行机制,也就是JVM后,也变得很容易理解了。
混淆点1:继承访问修饰符
访问修饰符:public、protected、private、默认的
公共的 可以随意访问
受保护的 不一定(相同包内可以)
私有的 不可以访问
默认的 不一定 只在包范围内等同于public
对于受保护的限制,用一个练习小程序实际测试后,得到:
子类内部使用,可以访问。
父类定义,子类为空:
子类创建于本包,父类也包含在本包中,可以访问
子类创建于本包,父类不包含在本包中,不可访问
子类创建于非本包,父类包含在本包,可以访问
子类创建于非本包,父类不包含在本包,不可访问
总结:父类protected,只跟父类是否跟子类处于同一包有关。子类创建时,protected被限定为子类定义包的范围。
父类定义,子类中使用public重写:
都访问为子类重写内容
总结:处于同包时,子类重写父类,处于不同类时编译器将直接找到子类的public定义。
父类定义,子类使用protected重写:
子类创建于本包,父类也包含在本包中,可以访问
子类创建于本包,父类不包含在本包中,可以访问
子类创建于非本包,父类包含在本包,不可访问
子类创建于非本包,父类不包含在本包,不可访问
总结:编译器首先在子类创建时,无法访问其他包内的子类protected定义,父类在本包内时定义被子类重写,父类不在本包内时接访问子类。
难点2:转型
向上转型(自动转型)
子类对象可自动转型为父类类型。
1:父类 转型子类对象 = new 子类名();
2:父类=子类;
这一块深入理解的话,需要考虑一个问题,为什么要子类转型为父类?从初学者的角度去看,是因为要面向对象,要减少编程工作量等等等等。
但是如果从设计者的角度去看的话,又会有一个问题,使用面向对象的思想来节约编程量,那么怎么设计编译器的运行方式才能让子类转型父类这一方法实际可用呢?
既然用了子类转型,那么转型后的数据一定有意义,也就是说转型后的父类数据一定会被访问到,不然我们直接delete这个子类就行了,还留着它干嘛?
既然子类已经转型为父类了,那么访问的时候,肯定是跟访问父类的时候一样,按照父类的方法和属性去访问。
那么设计者设计自动转型的时候,如果子类需要转型回来,那么就是暂时隐藏子类的方法和属性。如果子类不需要转型回来,那么直接裁剪子类的方法和属性。很显然java采用的是前者。(因为程序和属性都是以存储在内存中的,每个类都拥有一个定位这些内容的地址表,只需要修改隐藏或裁剪这个地址表就可以了)
向下转型
即父类再重新转回为子类,之前子类自动转型时,是会暂时隐藏子类特有的方法和属性,而转回时这些内容又重新见了。
当然还存在一些之前没有子类实例向上转型,而直接从父类转型到子类的功能。说实话,我觉得java的设计者压根就没想让人们这么用,估计也就只有一些面试官会问到。
(尽量少使用向上转型,或总是跟向下转型成对出现)
混淆点3:构造方法
子类跟父类构造方法:
构造方法顺序:
无参数(同C++一样,先进行父类构造方法,再进行子类方法)
父类→子类
//隐式的自动调用父类方法
父类有参数
需要定义在子类构造方法内第一句加上super(*)来调用父类方法。
父类无参数
编译器自动在子类构造方法第一句加上了super(),也可以自己手动加上。
混淆点4:接口
接口的定义:
1、属性全是默认为静态常量
2、只能写抽象方法。但是用来实现接口的类跟类是一样的
继承extends: 单继承 一个类只能继承一个类
接口implements: 多实现,一个类可以实现多个接口
接口与抽象类的区别:
接口和抽象类都需要被别的类继承,自己无法创建实例化对象。但一个类可以继承多个接口,却只能继承一个抽象类。抽象类只有方法是抽象的,没有抽象属性一说,而接口中的属性是static final的,相当于定义了一个立即数(即int i=10;中的10)。
界面开发
界面开发基础工具:
1、java.awt 早期的界面开发包 保留使用元素类型
2、javax.swing 升级之后的开发包 主要升级了视图部分
知识点1:基本工具
常用界面:
JFrame 最基础的界面类(用来盛放其他组件)
FlowLayout 流式布局
JLabel 标签组件
JTextField 文本框
JButton 按钮
JCheckBox 点选框
Jpanel 平台(用来盛放其他组件)
JScrollPane 自动滚动框平台
JMenuBar、JMenu、JMenuItem 菜单栏相关组件
监听器:
动作监听器 :ActionListener(是一个接口,重要方法getActionCommand)
鼠标监听器:MouseListener (跟事件监听器类似,重要方法get坐标)
事件类:
动作事件类: ActionEvent
事件发生时,会将组件以及事件的信息打包传入监听响应方法中。
查阅java文档,新建监听类,打印event内信息,即可大致了解jframe的事件大概包括些什么。
鼠标事件类:MouseEvent
类似于ActionEvent,只是内部的属性会有一些区别。
绘图:
Graphics 类
这是一个特殊的类,它被定义为抽象类。所以在定义的时候是无法new的。
获取方式:
从可视化组件上获取,在可视化之后获取。
首先我们思考一个问题,既然graphics是用来绘图的,那么绘图需要知道哪些要素?
像素坐标,像素值。
知道了这些信息,就可以挨个像素给显示设备发送数据,让显示设备刷新缓存。到了这里就明白了为什么graphics没办法在一开始就创建对象了,因为一开始就创建的对象没有意义。我们不知道像素的坐标该如何求得,因为java界面是一个程序,它不可能一直把整个屏幕都占据住,我们只能在自己规定好的区域内绘制图案。所以graphics需要附着在某些可视化组件上,从而获取像素坐标的相对零点。
即g = 组件.getGraphics();
然后像素值就可以使用一些基本的方法自动求得,比如:
g.setColor(color);
g.drawLine(x1,y1,x2,y2);
g.fillRect
g.drawImage
等等
重点1:监听器实现
1、用自己的类实现监听器接口:
在按钮监听类内,进行判断函数,并执行动作。然而执行动作时需要访问界面类p的属性,界面类被定义在main函数中,无法访问。
解决办法:在监听类内定义界面类,在界面类内将this引用传入监听类,即可完成访问。
2、将监听器类与需要监听的组件关联
自己的Listener loginl = new 自己的Listener();
组件.addActionListener(loginl);
重点2:重绘
了解重绘之前,首先要了解一下显示器的运作。先说一个最简单的12864模块,这是一块128*64的单色液晶显示器,它是如何显示内容的呢?
不断扫描。
就跟我们高中学到的电子管显示器一样,任何一个显示器都是逐点扫描的。12864模块中液晶屏幕接受到的数据其实包含了行和列,然后该坐标处的像素被点亮。至于像素的亮度(即像素值),可以改变电流大小,也可以改变电流通过的频率,不同设备使用不一样的方法。
但是很显然我们的cpu不可能一直来控制液晶屏幕哪个点需要被点亮,所以这时候需要显示驱动和硬件电路来配合。比如12864中可以缓存一部分cpu送来的数据,然后硬件电路自行帮我们解析数据,然后再给液晶屏发送电流。
至于个人PC的显示设备如何驱动,我不太了解,估计也是由程序给显示驱动发送数据,数据可能包括像素点,像素值,这些数据用来覆盖显示器缓存中的原始数据。
也就是说只要我们不发送新数据,显示器上的点不会改变。实际创建一个界面,使用graphics绘制之后,拖动界面边框,发现出现黑边,或者原有图像不见了,也确实印证了我们的想法。
那么如何实现重绘呢?根据观察,jframe自身的组件可以实现重绘,它会不断调用自身的paint函数来实现重绘。而我们如果想让我们自己绘制的图案也得到重绘的话,需要在paint中加入自己的重绘函数,(要加上super.paint从而绘制出界面中的基础组件)。
而重绘图案的话需要知道已绘制图案的信息,这里有很多方法,可以记录每个基础图形的信息,可以记录绘制动作的等等。
知识点3:山脉绘制(递归)
初学者总是认为递归很厉害,但是实际上递归真的是一种对程序员也不友好对计算机也不友好的实现方式。
首先考虑循环和递归的效率区别:
递归节省了少量的代码空间。
循环节省了大量的程序上下文空间。
在计算机中代码空间,一条指令基本上也就是32或64位这个样子,而程序上下文…至少传参要存储,程序指针要存储,临时变量要存储…
说正经的,递归其实就是用函数实现了循环和多重判断。
山脉递归就是不断取两侧中点,当发现间隔过小时返回。
知识点4:五子棋(博弈)
程序核心思想:
五子棋一共分为15*15个栅格,面向对象的思路是将每个落点设定为一个类的对象,该类名为Spot,包含X,Y,棋子颜色等属性。
比较简单的思路是创建一个列表,储存所有已经落下的棋子。
不过因为五子棋栅格一共只有225个,可以使用固定一位I维数组,这样相比于列表和二维数组来说加快了运行速度。
考虑胜负判断,我一开始写的递归,但是后来发现因为需要双向计算,所以递归其实并不方便(要调用8次函数!)。
后来老老实实使用了多重if嵌套,刚好这一套方法扩充以后就可以计算AI的权值了。
权重算法,落子后对每一个可能影响到的点重新计算权值,每次AI都会选择综合权值最大的一处栅格落子。
有了权重以后,就可以实现AI的单步对棋了。
要想实现多步推理,首先考虑一个旗手永远不肯能完成多步博弈,所以在推理时,必然会创建一个虚拟的对手,并令虚拟对手也使用某种推理方法进行落子。
然后考虑,每一种落子方式都将产生一个新的棋局,所以最终存储的棋局数是乘方增长的。
然后考虑,当我们得到所的棋局以后,该如何把未来的信息传递到当前呢?可以考虑用胜负情况传递回布尔收益来,也可以用将权值相加返回。但是由于越往后的步数局数越多,权值相加也越大,所以需要乘上一个衰减系数。
这是我的实现方式。但是效率很低,因为当推理步数多了以后,需要存储的棋局数太多了。
之后查阅资料发现,棋类博弈,一般使用剪枝算法,将已经决出胜负的棋局冻结,从而停止该分支的后续推理。剪枝算法最终计算效率要比不剪枝的算法节省几十甚至上百倍的空间。
引用深入理解
问题:任何非基本类型的对象,在java中都以引用表示,那么在java中是如何操作这些引用的呢?
初学者可能会认为,值传递(基本类型)在方法中会新建新的局部变量,方法结束便销毁,引用传递会直接拿到方法中去用。
这是错误的!
无论是值传递还是引用传递,在方法里都会新建新的局部数据。
public void test()
{
A cA=new A();//A的a会被初始化为1
cA.a=10;
System.out.println("cA:"+cA.hashCode());
change(cA);
System.out.println("cA"+cA.hashCode());
System.out.println(cA.a);
}
public void change(A su)
{
System.out.println("su:"+su.hashCode());
su=new A();
System.out.println("su:"+su.hashCode());
}
这一段代码的输出为:
cA:1295083508
su:1295083508
su:249155636
cA1295083508
可以看到,su这个传递的对象的哈希值在函数内确实被改变了,然而方法结束后,cA的哈希值并没有改变。
实际上,java的引用对象即是一个地址指针,在新方法中,会将这个地址复制一遍赋予一个新的指针。
也就是说如果直接修改这两个指针的话,他们是不会有任何交叉干涉的。但若是访问该指针所指的对象下的某个属性时,两者就会指向同一个数据,从而可以共同修改该数据。
也就是说一部手机A,A的通讯录里面存了一个手机号,在A传入一个新方法时,新方法会自动生成一部新手机B,但是这部手机的通讯录是复制自A手机的通讯录,他们都保存了同样一个手机号。
如果我们单独开关机A或B,他们两者没有任何关系。但如果打电话的话,A和B会打到同一个手机上去。
JVM指令初探
也叫作字节码,操作码(Opcode)。是一种java虚拟机所使用的的指令。
jvm指令
多线程
多线程其实属于java基础的进阶版,最好是掌握了基本语法以后再来看多线程。
混淆点1:thread和runnable
实现线程的两种方式:Thread、Runnable,实际上runnable是用来定义任务,thread用来定义一个线程。就好比thread是工人,runnable是工人要做的活。
所以实质上只有一种创建线程的方法,那就是new thread。thread类中自带一各runnable 名字叫target,无论是直接extends thread或是implements runnable然后new thread(runnable),最终结果都是一个thread被创建,然后执行一个runnable。
知识点2:基本用法
sleep:
在Thread类中有一个sleep函数,用于将当前线程等待,在java API中有这样一句话:
线程不会丢失任何显示器的所有权。 (翻译问题,The thread does not lose ownership of any monitors原意应该是说线程不会丢失监视器的所有权,应该是线程只是被阻塞(java线程的阻塞))。
在synchronized中的sleep不会丢失锁的所有权。
sleep无法被正常唤醒,只能通过中断方式唤醒。
wait:
wait仅看字面意思同sleep是类似的,使线程等待一定时间,使用notify可以将其唤醒。
并且wait和notify都是定义在Object这个类中,即java所有对象的根父类,即java从底层上支持多线程。
java API中关于wait的描述:
导致当前线程等待,直到另一个线程调用该对象的notify()方法或notifyAll()方法。 换句话说,这个方法的行为就好像简单地执行呼叫wait(0) 。
wait被绑定在一个Object上,调用wait的线程是当前正在访问此object的线程,通过使用synchronized来唯一确定具体是哪个线程正在访问。
所以wait一定要定义在synchronized关键字中,synchronized用于同步锁,即同一时间只能有一个线程访问synchronized控制下的对象。这样就很清楚了,使用synchronized来确定到底要wait哪一个线程。
为什么不直接wait(线程名)来实现线程的挂起呢?
经查阅资料发现,java在过去使用suspend() 和resume()来实现阻塞和唤醒线程,如thread1.suspend()。但是这种方式很容易发生死锁,所以被java抛弃了。(不建议使用)
notify:
跟wait结合使用,唤醒该被锁住的线程。若是同时有多个线程都在访问同一对象时被锁住了,可以使用notifyAll方法将他们都唤醒。
注意:sleep不会释放掉synchronized锁,wait会释放掉synchronized的锁。
如果在synchronized内join新线程,新的线程无法获得锁(会导致死锁!)。
join:
将某个已经start的线程加入进来。相当于把该线程的程序上下文直接拿过来直接继续执行。
interrupt:
interrupt中断用来打断等待睡眠(sleep、wait(await))等,中断标志位用来让用户自行使用。
interrupt(),跟cpu的中断不一样,java的中断只是模仿了cpu的中断。首先java的线程机制有一个判断标志位interrupted,通过isinterrupted()来访问。所以java的中断标志位需要程序员手动检查并考虑何种使用该机制。
所以会看到线程的run()函数中经常出现这样的代码
while(thread.currentthread().isinterrupted)
其实相当于提供了一个开关,可以由程序员来关闭或暂停这个run函数的内容。同cpu中断一样,只要检查了中断标志位后,中断标志位都会被置0.
interrupted()函数跟thread.currentthread().isinterrupted具有相同的功能,只不过interrupted不会将标志位清零,可以用来显示输出标志位。
在join中调用interrupt其实并不会直接打破join,而是中断掉了join中的sleep或wait,如果join中的代码一直执行是不会被断掉的。
重难点3:生产者消费者模型
生产者消费者模型,在java中,用synchronized实现生产者消费者模型很容易,而且不会发生问题,因为关键的访问都被重锁锁住了,是一种牺牲了效率的方法。如果用lock实现,则会变得复杂一些。
但实际上的生产者消费者模型可能存在诸多问题和陷阱。
知识点4:协同工作
CountDownLatch用来统计异步计数并控制等待。最好不要使用循环调用countDown(),因为即使计数到达0,只会唤醒await,并不会阻塞countdown的线程。也就是说countdownlatch的原意是希望有n个工人(countdown线程)同时执行一次自己的工作,当n个工作都结束后,告诉小工头(await线程)我们完成了,至于之后做什么,就要靠程序员自己写了。
CyclicBarrier,很多人以为它就是countdownlatch的可重复利用版,其实不是的,要不然为什么不直接叫cycliccount呢?实际上cyclicbarrier的功能跟countdownlatch有很大的不同,cyclicbarrier的原意是用在多个同时工作的循环线程中,即我们有n个工人(await线程)每个工人都不断执行自己的工作。假设没有使用cyclicbarrier,由于cpu的调度系统我们并不可知,所以一种极端的情况可能是某个工人做了十遍,而其他工人只做了一遍,那么这是非常糟糕的情况。所以我们希望做的快的工人能够在某个节点停下来,等一等其他工人,等大家都做完之后,再进行下一轮的工作,这时就要用到cyclicbarrier了。
cyclicbarrier的使用很简答,只需要在等待的位置加一句await就可以了,也可以在监督主线程中加一个await,同时在new cyclicbarrier时的总计数+1,实现监督的功能。cyclicbarrier有一个reset函数,它用来重置轮回,注意这个函数被设计的原意是希望,如果系统发生不可知的问题时,用来重新捋顺我们的循环调度系统。他会释放所有正在await的线程,并将当前计数重置,如果有线程被释放了吗,那么会抛出异常,所以正常状态下不要使用reset。
知识点5:executor框架
Executor接口,执行已提交的 Runnable 任务的对象。此接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。java建议使用executor,因为如果新手直接直接使用thread会发生很多意想不到的问题。
ExecutorService接口,相对于Executor来说提供了提供了生命周期管理的方法,可以让Callable任务返回 Future 对象,可以使用shutdown关闭。
Executors类提供工厂方法用来创建不同类型的线程池,既然多了一个s,那么就代表它可以用于多个线程同时使用的情况,但是不代表Executor只用于单个线程,executor是接口,提供了自身的方法,用于实现自身的设定,并不排斥其他的设定生效于自身,只不过其他设定需要单独实现。
可以根据需要创建线程的线程池newCachedThreadPool() ,创建包含单个线程的线程池newSingleThreadScheduledExecutor(),创建按计划进行的线程池newScheduledThreadPool() 等等…但是这些方法都是static的,意味着Executors这个类仅仅是一个控制者类,它并不实际执行任何事情。实际上,这些函数的返回值,也是返回一些非常实用的诸如ScheduledThreadPoolExecutor ,ThreadPoolExecutor等类。
AbstractExecutorService抽象类,继承自ExecutorService,将一些方法具体实现了,但是依然是基于接口方法的实现。
ThreadPoolExecutor线程池,是Executor这个大框架下的核心实现类。一个新任务进来时,线程池会自动创建新线程,当任务结束时,该线程变为空闲线程,等到新任务到来会重新被启动。线程数超过核心数后,新加入的任务会被暂时放在队列中等待。若任务太多,队列放满了,会新建新的线程。等到任务不多时,会将超出核心的空闲线程释放掉。直至总线程数达到允许的最大数目。
构造函数有以下参数corePoolSize:线程池中所保存的核心线程数,包括空闲线程。maximumPoolSize:池中允许的最大线程数。keepAliveTime:线程池中的空闲线程所能持续的最长时间。unit:持续时间的单位(毫秒或其他)。workQueue:一个阻塞队列。threadFactory:执行程序创建新线程时使用的工厂。handler : 由于超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。
其中threadFactory的作用就是创建新线程,只有一个方法newThread…
而workqueue是用来盛放新加入需要等待执行任务的一个阻塞队列,从线程池的角度,盛放任务的队列的长度可以为0,n和无穷,分别对应三种工作模式。
ThreadPoolExecutor的每一个线程如何实现工作的呢?其实是在线程的run函数内单次调用task的run函数…
线程池的作用:
提升性能:它在执行大量异步任务时,减少了每个任务的调用开销,并且它们提供了一种限制和管理资源(包括线程)的方法,使得性能提升明显。
统计信息:每个ThreadPoolExecutor保持一些基本的统计信息,例如完成的任务数量。
建议了解线程池的作用之前,自己尝试一种仅使用thread来加速传统单线程算法的例子。比如我就使用多线程来加速ArrayList的copy操作,当真正自己动手才会发现,由于线程不能多次start,所以要么把run写成循环,要么每次都创建新的线程。很显然后者带来的开销问题很大,而使用前者,则需要自己设计调度等待方式,我自己写的出现了死锁问题,所以使用官方提供的ThreadPoolExecutor是新手程序员的最优选择。
BlockingQueue阻塞队列,可以调度访问该队列的线程,是自动实现的生产者消费者模型。priorityblockingqueue实现优先级阻塞队列(需要自己定义优先级比较方式)。delayqueue是一个有延迟的阻塞队列,注意放入其中的元素是extends delayed的(这里是泛型中的extends,delayed是一个接口),需要有延迟时间属性。
ScheduledExecutorService计划执行服务接口,用来实现线程的按时序自动执行。主要方法schedule,用来将Runnable等任务建立成执行模型。
ScheduledThreadPoolExecutor 日程线程池执行,继承自线程池,同时实现了日程线执行的接口。即使用线程池的方式来安排在给定的延迟后运行或定期执行的命令。主要还是倾向于实现ScheduledExecutorService的接口,很多线程池方面的方法对它是无效的。简单来说,就是使用线程池实现定时器(用多个线程跑定时器任务)。
难点6:锁
java中的锁是定义在对象中的,每个实例化的对象都会有一个对象头,对象头中有状态字Mark Word,Mark Word记录了对象和锁有关的信息,当这个对象每次被访问时,都会检查一次Mark Word的信息。
Mark Word的状态有无锁、偏向锁、轻量级锁、重量级锁。
重量级锁:
重量级锁是非常紧密的,但开销也很大。重量级锁认为每次都有大量线程访问资源(悲观锁),需要一个管理者来管控这些贪婪的线程,保证每次只有一个线程持有锁并打开资源的房门,在里面纵火,等这个线程一阵操作索然无味之后,会退出房门,并释放锁。
每个申请访问资源的线程都会进入监视器,并在门外等待,第一个(或其他调度方式)到来的线程会获取锁,进入门内,当这个线程访问结束后,会退出。
这里需要注意,如果只考虑资源同步问题,退出的时候可以直接退出监视器,不需要等待。但是java中有wait和notify方法,那么退出的时候就需要加上一个退出等待区,如果开始wait则在退出等待区等待,直到收到notify信号才能离开。如果没有wait则直接退出。
如果锁有一个计数器,每次持有锁的线程访问都会使计数器+1,持有锁的线程退出就会使计数器-1,当计数器为0时释放该锁。这里主要用于线程自己调函数多次进入临界区的情况,这种锁就是可重入的锁。
注意:进入等待区和退出等待区的线程都是阻塞状态。
轻量级锁:
重量级锁确实是解决并发编程的稳定手段,但是重量级锁的开销实在是太大了,每次资源管控都需要进入离开监视器。
而有一部分乐观的编程者认为,某一项资源或许有时候仅仅只被几个线程访问,不需要使用过于复杂的重量级锁机制,所以使用了CAS方式。
CAS是一个CPU层级的原子性操作指令,在汇编指令中为 cmpxchg。即比较然后交换,指令会比较目标地址处的内容与参数内容是否一致,如果一致,则将第二个参数填入目标地址。
注意CAS操作实际上并没有锁住任何资源,它只是假设我在操作时没有其他线程访问,使用compare来验证这个的假设。所以CAS操作才有成功失败一说,成功即证明没有其他线程正在操作,而失败则证明有其他线程在竞争。但CAS并不完美,它有时会遇见ABA问题,所以CAS并不能替代锁的作用。但是乐观情况下,CAS确实比锁更有效。
每当线程准备访问一个对象头为轻量级锁的对象时,它会先尝试CAS操作,如果失败,自旋(多次尝试)一段时间后,如果还失败,那么认为该锁竞争激烈,将锁升级为重量级锁。
如果成功,就持有该锁,并执行代码。在代码结束时释放锁,检查期间是否有其他线程尝试访问,如果有就唤醒那些线程。
CAS确实是一种简易的判断竞争的方式,但是注意它并不是双向的,即没有竞争的话一定会CAS成功,但是CAS成功了并不一定证明没有竞争,例如ABA问题。所以CAS用于判断状态标志并改变,而不能直接用于原子操作。
偏向锁:
有了轻量级锁还不够,贪心的设计师们为了性能优化假想了更乐观的情况,那就是在大多数情况下只有一个线程访问该资源,这时候用到的锁被称为偏向锁(偏向一个线程)。
轻量级锁用到的CAS机制比重量级锁更为简便,然而偏向锁的假定可以连CAS都无需使用。
如果某个对象被某个线程访问时,对象的身上会留下该线程的痕迹。线程下次访问,发现痕迹跟自己一致,说明对象对自己很忠心,没有出轨(被其他线程访问),于是该线程会直接进入对象,并继续获得该对象的偏向锁(注意这里是继续获得,因为上一次退出时压根就没有释放,偏向锁不像其他锁一样用完就释放,即使空闲情况下也会一直保有,出现撤销时才计算是否释放)。
但若该线程发现留下的痕迹跟自己不一致,就证明该对象被别的线程访问过(它脏了),线程就会进入CAS机制,也就是说此时已经不是最乐观的情况了,迫不得已需要CAS开销了。
这时,如果CAS成功,证明当前偏向锁已经没有竞争了,于是该线程获取偏向锁,持有权直接转移到该线程上。如果失败,有可能是原线程还在执行,也有可能是一个新的线程跃跃欲试想要一起加入趴体。所以继续进行判断,判断原线程是否已经结束代码执行,如果没有结束,证明是原线程还在执行,那么说明有两条线程(原线程和现线程)在竞争这个锁,该锁升级为轻量级锁。如果已经结束执行,证明是新的线程进来了,把原线程的偏向锁释放,然后让这两个新的线程再次竞争偏向锁。
注意:偏向锁的CAS和轻量级锁的CAS干的活是不一样的,它们的参数不一样,修改的标志也不一样。
java程序一启动,所有对象都初始化为偏向锁,然后逐步升级为轻量级锁乃至重量级锁,不可降级。可以通过设置参数来改变初始是否初始化为偏向锁。
在某些其他jvm中,可以降级锁的等级。
lock:
lock是lock家族的根接口,提供了类似于synchronized的功能,但是synchronized是自动的内置关键字,lock可供程序员编程实现自己的方法。使用lock和unlock来施加释放锁。有ReentrantLock这个实现类。
lock申明要获取锁,如果锁被别的线程占有,则阻塞直至锁释放。如果持有锁的线程为当前线程,那么计数器+1(用于可重入)。
unlock释放已获得的锁。计数器-1(用于可重入)。当计数器为0时,锁便释放。
trylock尝试获取锁,如果锁没被占有,则获取锁。如果锁被占有,则始终阻塞(不会再被唤醒)。
在lock中需要用到AQS类,即一个叫做
AbstractQueuedSynchronizer的同步非阻塞队列,它执行acquire和release操作,分别用来尝试请求所有权和释放所有权。对那些没有请求到所有权的线程,会被阻塞并放入队列中暂时等待(如果所有权就是自己,执行重入操作,那么会先放入队列再移出)。
而在lock中实现各种操作计算原子性的工具就是CAS方法,被写为native的方法,同时使用volatile使数据可见。而实现阻塞,释放等底层操作的工具被写在LockSupport类中。
LockSupport用来创建锁和其他同步类的基本线程阻塞原语。 使用park和unpark来实现阻塞和释放阻塞,实际上代替了已经弃用的suspend() 和resume()方法。
ReadWriteLock也是lock家族的接口,用于提升读者写者问题的性能,对同一个对象来说,读取和写入使用不同的锁,一次可以有多个读者共同访问数据,但一次只能有一个写者写入数据,如果既有读者又有写者,按照某种规定给予优先权。有ReentrantReadWriteLock这个实现类。
readLock获取读者锁。
writeLock获取写者锁。
读写锁其实底层跟ReentrantLock非常类似,但是增加了更多的状态及判断用于实现读写分离。我们可以依据类似的思路,用ReentrantLock实现读+写+偷窥者锁,优先级读写锁等等(发挥想象力)。
condition是一个用来描述线程状态改变的接口,其下包含AbstractQueuedSynchronizer.ConditionObject 和AbstractQueuedLongSynchronizer.ConditionObject实现类。condition与lock是紧密相关的,condition被绑定在lock对象上。在concurrent.locks 包中实际上lock 替代了 synchronized 方法和语句的使用,condition 替代了监视器方法的使用。
在condition中用await()替代wait,用signal替代notify(),用signalAll()替代notifyAll()。condition实际上相当于我们在synchronized 代码块中创建一个马甲对象(仅仅用来wait和notify线程的对象,没有任何属性和方法)。
synchronized:
synchronized修饰方法,方法锁,同一时间只能有一个线程使用该类方法。
synchronized修饰代码块,括号中是对象,对象锁,同一时间只能有一个线程访问该对象。
synchronized修饰代码块,括号中是类.class,类锁,同一时间只能有一个线程访问该类对象。
被synchronized修饰的带买块会在字节码中自动生成monitorenter和monitorexit以及异常抛出代码块,从而实现监视器控制锁的过程。
旧版本的Synchronized只能是重量级锁,所以一些博客也称Synchronized为重量级锁。
volatile:
volatile用于保证数据的可见性,它是修饰变量的,而不是修饰代码块的。每次当被volatile修饰的变量在某个线程内发生改变后,它会自动更新到主内存中,并通知其他线程立刻从主存中更新数据。
被volatile修饰的变量在生成字节码时,会多出一个flags属性ACC_VOLATILE,该属性会在字节码生成cpu指令时添加上lock前缀。加了lock的汇编机器码就使CPU在执行这条指令的时候会把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住(总线上的所有数据无法传输),从而保证执行这条指令时只有一个cpu从总线上存取数据。
这里的重点是从总线上存取数据,对于已经取入cpu寄存器的数据不会进行任何限制,也就是说volatile无法保证原子性,是一个残缺的锁(当运算操作不频繁时可以当做锁来使用)。
总结:
Synchronized更多的靠jvm来管理,大多数时候不涉及操作系统,是java自己在调度自己的线程,在1.6以后变为偏向锁,轻量锁,重量锁的升级过程。
lock跳过了jvm的顶层管理,lock中都是直接向操作系统申请线程管理,但实际上在管理方式上是类似的。如果拿ReentrantLock在单线程多次访问时,实现了代码层面上的重量级锁,但并没有直接像重量级锁一样调用操作系统的互斥量等等功能,所以性能上类似于偏向锁或轻量级锁。但若是悲观情况下,ReentrantLock即执行了代码层面上的重量级锁又实际执行了系统层面的重量级锁,开销应该是比Synchronized要大的。
但是ReentrantLock编程比较灵活,所以在实现大型项目的基础工具时尽量使用ReentrantLock。不过Synchronized的可读性更好,如果需要经常与人合作,那么debug最好使用Synchronized,之后的release版本可以再改为ReentrantLock。
愚见:个人以为轻量级锁属于历史残留,是一个没用的东西。偏向锁的功能和轻量级锁类似,且轻量级锁和偏向锁都会自动升级,所以如果不能降级的话,轻量级锁其实属于一个中间态(两次偏向锁)。如果一个大型程序稳定运行一段时间后,系统中的偏向锁应该和重量级锁应该占了绝大多数。仅为个人人观点,未经测试。
难点7:原子
java的原子类型在atomic包内,有AtomicInteger, AtomicLong, AtomicBoolean三种基本类型。基本原子类型实现跟基本类型一样的运算,不过这些运算都是原子性的(基本都是利用CAS实现的)。
AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray三种数组类型。但是这里的array并没有list的功能,也就是说它只是一个数组Int[] 、long[]等。不能add和remove,只能set或者get,当然在set和get时都是原子性的(使用CAS实现,但由于CAS本身具有ABA问题,所以直接用CAS实现原子类只能保证变量的原子性,不能保证代买快的原子性。另外如果使用了自己计算的期望值compareAndSet,也会发生问题。在分布式情况下出现)。
ABA问题示例:
atom线程:
for(int i=0;i<10000;i++)
{
atom.compareAndSet(i,i+1);
}
这样无论我启动多少个atom线程,最后结果都会是10000.
而加入一个破坏线程
atomcrash线程:
while(!tt.shut)
{
atom.getAndIncrement();
atom.getAndDecrement();
}
此时某些atom线程的comparandset就会失败,会停在某个数字上。
AtomicReference, AtomicStampedRerence, AtomicMarkableReference三种引用类型。相当于设置了对象引用的原子类,但是这三者的CAS比较的是对象的地址。
AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicReferenceFieldUpdater三种属性修改类型。这三者其实就是把某个对象的属性视为了基本原子类型进行操作。为了将对象的属性作为参数传入更新器,需要使用一个String指定变量名,然后java会将它定位到class字节码中的字段内容。
由于java的原子类是由CAS实现的,而没有使用互斥,所以会有ABA问题,但由于基本类型的ABA实际上值没有发生改变,而基本类型也只需要用到值,所以大部分情况下还是没问题的。但是引用类型就不一眼了,我拿出一个引用类型A改成B再改成A,很显然这个类型已经被篡改过了,所以java提供了引入版本号的AtomicStampedRerence原子类。
数据结构
数据结构是一个比较通用的内容,在各种编程语言中都会出现,在面向不同任务时也都需要用到。数据结构顾名思义是用来存储数据的,数据结构永远无法单独拿出来说,它跟底层硬件、操作系统、内存模型、输入输出、工作内容等都是息息相关的。
java中的数据结构从collection基类开始,衍生为无数存储数据的容器。
基本元件
Collection ,根接口,定义了最基本的方法,比如add,remove,size等。但它是Iterable接口的子接口。
Iterable,直接理解可迭代的有点困难,它代表了所有能被迭代器访问的结构。在java中,iterable只有一个方法,那就是返回一个迭代器iterator。foreach可以用来快速实现迭代器访问。
迭代器到底是什么?
指针。
任何一种数据结构,它都是存储在内存中的,既然存储在内存中,那它就需要按照访问内存的方式进行访问,而访问内存的方式就是指针。java中虽然隐含了指针,但在JVM中依然是在用指针访问数据,同样的,C++中迭代器,iter=auto 可迭代对象.first,就是获取了一个指针。
任何语言任何框架的iterator类对象,都有一个指针属性,该属性指向本迭代器所指的数据,那么我们常用的iter++(即数据结构元素下标+1),并不是物理意义上的指针地址+1。而是通过某种运算,让指针指向数据结构中下一个元素。
比如数组数据就是,next地址=地址+1*(单个数据长度),链表数据就是next地址=下一个元素的地址。
快速失败和安全失败,快速失败,当迭代器在访问元素时,如果原来的数组发生某些改变(被其他线程调用了某些方法),会触发快速失败,抛出异常。快速失败是通过判断总修改次数来实现的。
安全失败,在迭代器访问之前,先将数组整个拷贝一份(相当于放在了threadlocal),然后对这个拷贝进行访问,但并不能感知到元素发生改变。
List可重复列表,可随意插入。
ArrayList数组列表,用数组Object[]实现的可增删改插的列表。
LinkedList链表列表,用地址链接实现的可增删改插的列表。
vector同步数组列表,与arraylist差不多,只不过方法都被synchronized锁住了,实现了同步。但是这种同步是重中之重锁,如果同时有很多vector对象的话,不同线程去访问不同vector按理说不会发生同步问题,但也会被锁住,所以很多对象时不建议用vector。
Queue按顺序出入的队列,queue其实跟list的实像方式都差不多,只不过它只需要能够在队尾和队首操作元素,一般情况下使用先入先出的模式(queue接口中定义的remove和add也是移除队首,添加队尾),像打饭排队一样。
Deque双端队列,可以在队首和队尾分别添加移除。
ArrayDeque双端队列的实现类,只不过它在移除队首元素时,没有重新copy数组,而是把首位下标增1,同时null了原来的首位元素。所以实现队首移除时速度会非常快。
PriorityQueue具有优先级的队列(正在学习)。
Set,set最大的特点就是不包含重复元素。修改set中的元素可能会造成未知的情况。
SortedSet排序集合的一种实现类,使用Comparator进行比较,可以根据截断值返回部分元素。
HashSet,由hashmap支持的排序集合。
EnumSet枚举集合,使用枚举元素在定义时的顺序来排序,只能存储枚举类型的类。
LinkedHashSet
TreeSet
(…待增添)
泛型
首先容器是一种泛型工具,java中泛型跟C++差不多,直接拿来用就可以了,不同的是java常用E(元素),而C++常用T(模板)。
更重要的一点是,java中所有的泛型都只针对引用,而C++的模板可以包含基本类型。所以java的泛型在使用基本类型时,需要使用基本类型的包装类。
包装类是个很复杂的过程,简单点说是做什么的呢?
把实实在在的数据虚化为一个对象。
即包装类实现了赋予基本类型数据一个指针的作用,类似于int* p=&a(某个基本类型数据);而在这一过程中,会有一些其他操作,比如Integer的-128~127,这些其实不太重要,因为只是java用来减少内存使用的小trick,在大厂的规范中,基本包装类都会强制要求使用.equal来比较大小。
获得了包装类之后,就可以跟普通对象一样存入容器中了,现在所有的数据类型全部变成了引用,也就是说java的所有容器,内部存储的实际上都是引用,而没有真实存在的数据存在里面。所以我说java的容器是虚的。
那么java的泛型如何实现的呢?
转型
java的容器全部使用Object作为元素,即所有java类的根类,存入容器时会自动向上转型,变为Object存入,而取出容器时,会在return时手动转型为E。
ArrayList
思考ArrayList如何实现自动改变大小的功能。之前在书上看到容器会自动分配内存空间,并随着自身大小调节新分配的内存空间,还以为有什么神奇的操作可以自动实现这一功能。
结果打开ArrayList源码,发现仅仅只是用了copy…这也告诉我们一个道理,无论多么高端的算法,最底层无非就是不断的赋值,不断的加减乘除运算罢了。
ArrayList将内容都存储在数组中,不断在内存中分配一个大于现容量的固定数组,并重新为数组赋值,不断重复此过程从而实现容器的功能。
注意:删除元素时除了减小数组大小的int,还需要将最后一个元素指向null(不然空间将始终占据)。
生长方式
每次当元素个数等于数组大小时,新增元素时就需要生长数组了,很显然,生长数组的次数越小越好,但是如果单次生长过多,很多空间会被浪费掉。
这个就需要针对性分析了,所以大型项目中,最好extends官方ArrayList然后自行修改生长方式。
我实现时考虑实际生产中,增添总是缓慢的,而删除总是迅速的,所以增添时生长缓慢进行,而每次删除则间隔很久。
还可以使用自适应的方法,使用线程不断监控系统中此类对象对内存和cpu的使用情况,从而不断规划出最适应的方案。
arraycopy
这是java的一个native方法,我自己写的arraylist使用=号进行赋值copy操作,而官方arraylist使用arraycopy函数。
结果实现效率一个天上一个地下。所以对于程序员来说,大量数据拷贝时建议使用arraycopy。
多线程加速
考虑到arraylist最为耗时的一部分就是copy操作了,所以我是用了多线程加速,结果发现,arraycopy函数放在线程run函数内的运行时间远大于放在主线程的运算时间。执行1000次,一个6000ns,一个18000ns,刚好差了三倍,而我的cpu只有四核…所以多线程加速下来,即使不考虑并发损失,也是只亏不赚的。
LinkedList
链表像一条链子一样前后连接。信息通过链接逐层传递,而不是像列表一样数格子直接被提取。
提起链表,自然会想到链表有单向(正向、逆向),双向之分,双向肯定是功能最全,但是也比单向的多了一各额外的引用需要存储。
实现链表,首先思考链表的数据存储在哪?
列表是将数据存储在数组内的,链表是将数据离散存储在内存空间中的,使用数组肯定不行。每次新给链表添加一个数据时,在内存中任意一块(其实不是任意,涉及到操作系统的内存管理)空间开辟出一个新的地址用来存储。这听起来很像是new关键字,所以我们存储数据就用一个类来存储。
ArrayList:
一个arraylist主类,包含一个object[]数组。
LinkedList:
一个LinkedList主类,包含头节点尾节点的指针,n个node类,用来存储数据及链接地址。
node类要包含一个泛型用来存储数据,还有包含指向上一个节点和下一个节点的通道,即引用(地址指针)。
接下来考虑链表的主类,包含一个起始入口,方便我们找到第一个节点,同时也需要一个末尾入口,方便我们在末尾进行添加新节点的操作。
删除插入节点
在中间插入节点或删除很容易出问题。如果先将整个链表断开,然后再插入可不可以呢?
这是很典型的人类思维,在这种想法中,大脑是并行计算的,一部分负责维持前半段链表的存在,一部分负责后半段链表的存在。
然而在计算机中,当链表被断开时,只能串行计算,在断开的一瞬间,没办法立刻将后半段链表定位,也就是说断开以后后半断链表将没办法再通过前向方式定位(还可以从last开始反向定位)。
这就好比我们在给数组列表不断删除元素时,忽略了数组列表的长度也在变化的问题。
所以需要某种方式避开数据丢失或临时保存数据,我采用的是创建临时节点来保护定位关系,也可以用别的方法。
遍历
遍历方法很简单,如果是单向链表,那么在循环内逐步定位寻找下一个节点就可以了。
如果是双向链表,先判断一下访问元素的位置与头尾节点的距离。
但是思考一个问题,无论何时查找哪个节点,都需要从起始节点或末尾节点开始逐步遍历,如果链表很长将很耗时。
考虑到局部性原理,我们可以增设一个指针节点,用来指向上一个操作结束时的节点位置,当顺序遍历时,下一个元素距离历史指针的位置只需要查找一次就可以了,这样遍历的速度可以大大加快。
对比
测试比较:
array官方ArrayList,linked官方LinkedList
一万次末尾add操作:
array add10000:1195800
mylist add10000:1055000(因为我的列表增长机制设置的比较缓慢,所以这里直接给了一个初始数组极大,所以没有增长。)
linked add10000:1257900
mylink add10000:1892400
末尾add的比较没什么意义,只是比较了代码的冗余度。
一万次0号位置add操作:
array add[0]10000:16887200
mylist add[0]10000:16271700
linked add[0]10000:4438500
mylink add[0]10000:3024900
对于列表来说,0号位置add比较的是算法的copy效率,换成system.arraycopy后mylist跟array速度几乎一致。
链表的0号位置加入元素理论上应该与末尾位置加入元素是相同的,然而官方的LinkedList源码中给0号位置添加元素的代码比较冗余,所以时间mylink要长一些。
一万次固定位置插入:
linked add[10]10000:16990500
mylink add[10]10000:8860000
由于我的链表使用了指针进行定位,所以每次固定位置插入的时候只有第一次需要定位,之后就不需要定位了,所以速度比官方LinkedList快很多,位置数值越大,差异越明显。
一万次随机位置插入:
linked add[r]10000:228743400
mylink add[r]10000:144444700
因为官方是从起始和末尾找最快路径。
我是从起始、末尾以及指针处找最快路径,所以还是快了一点,暂且这么认为。
从头到尾遍历:
array get10000:1467700
mylist get10000:454400
linked get10000:88418600
mylink get10000:1378900
数组列表为什么我的比较快,我看了一下ArrayList的源码,官方写的代码太冗余了,几个函数调来调去(节省了代码空间,浪费了运行效率)…
随机遍历:
array getr10000:2627300
mylist getr10000:3052500
linked getr10000:92970700
mylink getr10000:87163900
随机遍历的速度都差不太多,很显然随机取出的时候没有比随机插入更有优势,这就说明随机的情况下拥有指针的链表其实并不是快在查找部分,而是快在了断开和插入的代码上。
说实话测了很多次,结果差异很大,可能跟纳秒计算方法以及系统调度有关,很多时候自己的链表运算时长直接飙出十几倍。另外同样一行代码,放在不同位置去执行,时间差异也差出四五倍,java这个JVM机制还真是神奇…
小项目
2D游戏
多线程
一个计算线程可以用来实现物体运动(坐标改变),简单碰撞检测(if判断),游戏逻辑(伤害、生命值计算),复杂碰撞检测(分区域树状结构全图检测),新建物体(赋予初始值,并加入gameobject数组),物体销毁(从gameobject中remove,并更新其他所有物体的元素下标),游戏触发器响应(如果不独立设置线程的话,需要在计算线程中循环判断布尔值),UI响应(不使用java监听器的话,可以用计算线程来实现)。
一个绘图线程可以用来实现绘制图案。
据我所知在某些真正的引擎中,这两个线程的运行间隔是相等的,每帧都进行计算及绘制。但是我感觉这样很浪费,如果想追求高刷新率的话就要求计算率也很高,但其实在游戏中是用不到的。
如果使用多线程来实现计算的话,又会需要考虑到各种资源锁增大开销,所以我把所有的计算放到一个线程中,并增大该线程的休眠时长。
GameObject
我们的gameobject是所有游戏对象的基类,它包含一部分公有属性,和必须的方法。比如 种类(int),坐标(int),计算坐标(double)(这里其实没必要两个),速度(double),加速度(double),可以移动(boolean),可以绘制(boolean),动画师(animator),当前图层数(int),图层中数组元素下标(int),法向量(pve),触发状态(boolean)(也可以把所有的触发状态都装入一个触发器类中,但频繁触发的情况下性能不佳)。
抽象方法:move(用于计算线程调用),paint(用于绘制线程绘制),paintMap(用于绘制小地图),destroy(用于销毁对象)。并设置一些常用的函数usuallyDestroy,speed2Pve,maxSpeed,usuallyShowAnim,changeAnim等等。
gameobject的子类就很丰富了,比如
背景板:用于装载在背景图层(最底层),没有什么其他属性。
小球:小球总计数,初始构造函数,能量,能量消耗,群落中心,中心点计算(需要被计算线程调用)。
小人:小人总计数,初始构造函数,实例化了animator并设置了相关animation。
绘制
双缓冲绘图:
新建bufferedimage,将全部绘制在此bufferedinage上完成,完成后再将这个bufferedimage一次性绘制到jframe或jpanel上。
若逐个绘制游戏对象,当游戏对象很多时,发送给显示器的数据,显示器接受不过来(cpu计算速度远远快于发送数据的速度),所以会影响游戏运行速度,导致画面闪烁。使用双缓冲绘图消除闪烁。
但是bufferedimage的绘图质量较低,需要放大绘制后再缩小显示,相关变量quality)
剪切绘制:
如果游戏地图大于显示窗口,那么需要将bufferedimage剪裁一部分,并只显示这一部分。
camera:
使用camera类用于定位摄像机位置(在2D游戏中就是一个显示窗口xy相对于大地图的位置,3D游戏还需要设置外参),使用拖动或定位小地图等方式改变camera的坐标。
定时器
使用java的Timer用于定时器任务,这里可以让定时器任务直接运行代码,也可以设置触发,然后等待下一次的计算线程执行再代码。前者增加了锁的开销,后者增加了if判断的开销。
更进一步可以使用线程池初始化定时器。
音效
找了半天,java原生中貌似只有clip可以实现播放音效效果,而且现在已经被弃用了。
场景切换
这是很复杂的功能,利用一个scene类,包含最基础的jpanel(用于清除重加载挂载在其上的java组件,如按钮等),一个scene是一个完整的游戏,包含之前提到过的所有功能。
一个scene如果需要绘制的话,需要有自己特有的绘制线程(计算线程一般只需要一个就行了,除非游戏内内嵌小游戏)。
scene类包含sceneInit,display,hide,destroy等抽象方法,每一个scene都需要重写这四个方法。
在监听器或者自己的函数内调用不同scene的sceneInit,display,hide,destroy函数实现场景创建、场景切换和场景摧毁。
但是对大多数scene的子类来说都是单例对象,而那些需要批量化创建的子类来说,需要有scene参数来规划scene内的图案和布局。
一个过场动画类extends自scene,实现了多对象scene。该过场动画包含背景,左立绘,右立绘,text框。有一个命令属性数组,该数组内存储了流程化建立过场动画的所有命令,每个命令都将解析为各种函数(如settext,waittime,changanim,palyclip等),命令可以存储在外部txt文件中,从而实现代码数据分离。
资源管理
一个AssetManager类用于管理资源,它是一个单例类,会在游戏开始时的加载scene中加载所有资源。
在游戏过程中,如果某个gameobject需要使用到相应资源,需要遍历if找到该资源(可以用hash实现),并将引用定位到该资源。
资源管理避免了资源重用的问题。
最后
以上就是无限橘子为你收集整理的java学习 connect(1,2,3,4)框架基础数据结构小项目的全部内容,希望文章能够帮你解决java学习 connect(1,2,3,4)框架基础数据结构小项目所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复