For my voxel game, i have (only) implemented:
- Rendering block faces only adjacent to another face.
- Using one VBO per chunk (And another for liquids)
- Frustrum culling at chunk level
- Backface culling
Altough I haven't done it myself, you can also perform some greedy meshing algorithm, "mixing" same type cubes into same triangles. Imagine you have 4 stone blocks, collindant to each other. When drawing the front face of each block you could draw 2 triangles per block, being 8 triangles in total. However, you can take advantage of the fact that all those blocks are of the same type, and you can draw 2 big triangles that cover all those 4 blocks instead.
However this method can become tricky to use when each block haves different brightness levels, but anyways, considering that in a voxel game most of the blocks often have the same brightness (No artificial light and full sunlight or no artificial light and no sunlight), this can greatly improve your perfomance.
Nonetheless, i don't think your problem lies there. With almost the same optimizations than you, I can run smoothly more than 14440 chunks, while performing gpu-heavy algorithms (Cascaded shadow mapping, water shading), in a fairly old PC.
For all those optimizations, the most important one is "Only rendering block faces that aren't adjacent to another face". Before implementing it, I was only able to draw ~20 chunks at most without lagging. Almost all the perfomance improvements i had been able to achieve relied on making this optimization work on all cases it should (For example, in the chunk limits, being for example x=0 and x=15, some of the collindant cubes are outside the chunk. Are you considering this special case? If not, you will be drawing the "outline" of each chunk, killing perfomance). If you disable collisions and go "into" the terrain, you should ideally not see anything drawed, only the terrain outline. It is possible that your problem lies there.
There is a simple test that can help you. Count the number of triangles you are generating, using a static var. Create a flat world, being only the y=0 cube coordinate of the y=0 chunk full, and the rest of the cubes empty. Check how much triangles the program had generated. Now, do a full world, with all cubes full excepting the last cube layer of the last chunk, being air. If the "Only rendering block faces that aren't adjacent to another face" optimization works right, the number of triangles generated should be equal than the number of triangles in the "empty world" case, altough the number of cubes is way higher.
Also, you should check that you are only binding textures at the beggining of the program (Or at least only a few times when rendering), that you are using the same shader program without switching it when drawing each chunk, etc.
Hope it helps.